♻️ Merge :thumbnails and :thumbnails-meta into single state key (#10021)

* ♻️ Merge :thumbnails and :thumbnails-meta into single state key

♻️ Unify thumbnail refs in a single ref

🐛 Fix test

* ♻️ Update tests
This commit is contained in:
Luis de Dios 2026-06-30 14:37:27 +02:00 committed by GitHub
parent b3b3ea97db
commit 46f5346045
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 292 additions and 279 deletions

View File

@ -275,7 +275,7 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(-> state (-> state
(assoc :thumbnails thumbnails) (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil})))
(update :files assoc file-id file))))) (update :files assoc file-id file)))))
(defn zoom-to-frame (defn zoom-to-frame

View File

@ -800,7 +800,8 @@
(ptk/reify ::library-thumbnails-fetched (ptk/reify ::library-thumbnails-fetched
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(update state :thumbnails merge thumbnails)))) (update state :thumbnails merge
(d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))))))
(defn fetch-library-thumbnails (defn fetch-library-thumbnails
[library-id] [library-id]
@ -1541,7 +1542,8 @@
(->> (rp/cmd! :get-file-object-thumbnails {:file-id library-id :tag "component"}) (->> (rp/cmd! :get-file-object-thumbnails {:file-id library-id :tag "component"})
(rx/map (fn [thumbnails] (rx/map (fn [thumbnails]
(fn [state] (fn [state]
(update state :thumbnails merge thumbnails)))))))))) (update state :thumbnails merge
(d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))))))))))))
(defn link-file-to-library (defn link-file-to-library
[file-id library-id] [file-id library-id]

View File

@ -134,12 +134,11 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(let [uri (dm/get-in state [:thumbnails object-id])] (let [uri (dm/get-in state [:thumbnails object-id :uri])]
(l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri) (l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri)
(-> state (-> state
(update ::thumbnails-deletion-queue assoc object-id uri) (update ::thumbnails-deletion-queue assoc object-id uri)
(update :thumbnails dissoc object-id) (update :thumbnails dissoc object-id))))
(update :thumbnails-meta dissoc object-id))))
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ stream]
@ -156,13 +155,12 @@
(ptk/reify ::assoc-thumbnail (ptk/reify ::assoc-thumbnail
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(let [prev-uri (dm/get-in state [:thumbnails object-id]) (let [prev-entry (dm/get-in state [:thumbnails object-id])
now (.now js/Date)] now (ct/now)]
(some->> prev-uri (vreset! prev-uri*)) (some->> prev-entry :uri (vreset! prev-uri*))
(l/trc :hint "assoc thumbnail" :object-id object-id :uri uri) (l/trc :hint "assoc thumbnail" :object-id object-id :uri uri)
(-> state (-> state
(update :thumbnails assoc object-id uri) (update :thumbnails assoc object-id {:uri uri :rendered-at now}))))
(update :thumbnails-meta assoc object-id {:rendered-at now}))))
ptk/EffectEvent ptk/EffectEvent
(effect [_ _ _] (effect [_ _ _]

View File

@ -168,7 +168,7 @@
(ptk/reify ::persist-thumbnail (ptk/reify ::persist-thumbnail
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [data-uri (dm/get-in state [:thumbnails object-id])] (let [data-uri (dm/get-in state [:thumbnails object-id :uri])]
(if (and (some? data-uri) (if (and (some? data-uri)
(str/starts-with? data-uri "data:")) (str/starts-with? data-uri "data:"))
(let [blob (wapi/data-uri->blob data-uri)] (let [blob (wapi/data-uri->blob data-uri)]

View File

@ -580,14 +580,9 @@
[object-id] [object-id]
(l/derived (l/derived
(fn [state] (fn [state]
(some-> (dm/get-in state [:thumbnails object-id]) (when-let [entry (dm/get-in state [:thumbnails object-id])]
(cf/resolve-media))) (cond-> entry
st/state)) (:uri entry) (update :uri cf/resolve-media))))
(defn workspace-thumbnail-rendered-at
[object-id]
(l/derived
#(dm/get-in % [:thumbnails-meta object-id :rendered-at])
st/state)) st/state))
(def workspace-text-modifier (def workspace-text-modifier

View File

@ -136,10 +136,11 @@
width (dm/get-prop bounds :width) width (dm/get-prop bounds :width)
height (dm/get-prop bounds :height) height (dm/get-prop bounds :height)
thumbnail-uri* (mf/with-memo [file-id page-id frame-id] thumbnail-data* (mf/with-memo [file-id page-id frame-id]
(let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")] (let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")]
(refs/workspace-thumbnail-by-id object-id))) (refs/workspace-thumbnail-by-id object-id)))
thumbnail-uri (mf/deref thumbnail-uri*) thumbnail-data (mf/deref thumbnail-data*)
thumbnail-uri (:uri thumbnail-data)
modifiers-ref (mf/with-memo [frame-id] modifiers-ref (mf/with-memo [frame-id]
(refs/workspace-modifiers-by-frame-id frame-id)) (refs/workspace-modifiers-by-frame-id frame-id))

View File

@ -328,19 +328,18 @@
(mf/with-memo [file-id page-id root-id] (mf/with-memo [file-id page-id root-id]
(thc/fmt-object-id file-id page-id root-id "component")) (thc/fmt-object-id file-id page-id root-id "component"))
thumbnail-uri* thumbnail-data*
(mf/with-memo [object-id] (mf/with-memo [object-id]
(refs/workspace-thumbnail-by-id object-id)) (refs/workspace-thumbnail-by-id object-id))
thumbnail-data
(mf/deref thumbnail-data*)
thumbnail-uri thumbnail-uri
(mf/deref thumbnail-uri*) (:uri thumbnail-data)
rendered-at* thumbnail-rendered-at
(mf/with-memo [object-id] (:rendered-at thumbnail-data)
(refs/workspace-thumbnail-rendered-at object-id))
rendered-at
(mf/deref rendered-at*)
modified-at modified-at
(some-> (:modified-at component) (.getTime)) (some-> (:modified-at component) (.getTime))
@ -349,9 +348,9 @@
;; or the component was modified after the last render ;; or the component was modified after the last render
stale? stale?
(and (some? thumbnail-uri) (and (some? thumbnail-uri)
(or (nil? rendered-at) (or (nil? thumbnail-rendered-at)
(and (some? modified-at) (and (some? modified-at)
(> modified-at rendered-at)))) (> modified-at thumbnail-rendered-at))))
on-error on-error
(mf/use-fn (mf/use-fn

View File

@ -258,7 +258,7 @@
dest-shape (cond-> dest-shape dest-shape (cond-> dest-shape
(some? thumbnail-data) (some? thumbnail-data)
(assoc :thumbnail thumbnail-data))] (assoc :thumbnail (:uri thumbnail-data)))]
[:g {:on-pointer-down start-move-position [:g {:on-pointer-down start-move-position
:on-pointer-enter #(reset! is-hover-disabled true) :on-pointer-enter #(reset! is-hover-disabled true)
:on-pointer-leave #(reset! is-hover-disabled false)} :on-pointer-leave #(reset! is-hover-disabled false)}

View File

@ -9,6 +9,9 @@
[app.common.thumbnails :as thc] [app.common.thumbnails :as thc]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.workspace.thumbnails :as thumbnails] [app.main.data.workspace.thumbnails :as thumbnails]
[app.main.repo :as rp]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.mock :as mock]
@ -42,257 +45,267 @@
:component-root true}}}}}] :component-root true}}}}}]
(t/is (= #{["frame" root-id] (t/is (= #{["frame" root-id]
["component" shape-b-id]} ["component" shape-b-id]}
(#'thumbnails/extract-frame-changes page-id [event [old-data new-data]])))) (#'thumbnails/extract-frame-changes page-id [event [old-data new-data]])))))
;; --- Batch deletion queue state management --- ;; --- Batch deletion queue state management ---
(t/deftest clear-thumbnail-adds-to-deletion-queue (t/deftest clear-thumbnail-adds-to-deletion-queue
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/test-thumb" uri "blob:http://localhost/test-thumb"
event (thumbnails/clear-thumbnail file-id object-id) event (thumbnails/clear-thumbnail file-id object-id)
state {:thumbnails {object-id uri}} state {:thumbnails {object-id {:uri uri :rendered-at nil}}}
result (ptk/update event state)] result (ptk/update event state)]
;; Thumbnail removed from the map ;; Thumbnail removed from the map
(t/is (nil? (get-in result [:thumbnails object-id]))) (t/is (nil? (get-in result [:thumbnails object-id])))
;; Object-id added to the deletion queue with its URI ;; Object-id added to the deletion queue with its URI
(t/is (= uri (get-in result [deletion-queue-key object-id]))))) (t/is (= uri (get-in result [deletion-queue-key object-id])))))
(t/deftest clear-thumbnail-keeps-other-thumbnails (t/deftest clear-thumbnail-keeps-other-thumbnails
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri1 "blob:http://localhost/thumb-1" uri1 "blob:http://localhost/thumb-1"
uri2 "blob:http://localhost/thumb-2" uri2 "blob:http://localhost/thumb-2"
event (thumbnails/clear-thumbnail file-id object-id1) event (thumbnails/clear-thumbnail file-id object-id1)
state {:thumbnails {object-id1 uri1 object-id2 uri2}} state {:thumbnails {object-id1 {:uri uri1 :rendered-at nil}
result (ptk/update event state)] object-id2 {:uri uri2 :rendered-at nil}}}
;; Only the cleared thumbnail is removed result (ptk/update event state)]
(t/is (nil? (get-in result [:thumbnails object-id1]))) ;; Only the cleared thumbnail is removed
(t/is (= uri2 (get-in result [:thumbnails object-id2]))) (t/is (nil? (get-in result [:thumbnails object-id1])))
;; Only the cleared thumbnail is queued (t/is (= uri2 (get-in result [:thumbnails object-id2 :uri])))
(t/is (= uri1 (get-in result [deletion-queue-key object-id1]))) ;; Only the cleared thumbnail is queued
(t/is (nil? (get-in result [deletion-queue-key object-id2]))))) (t/is (= uri1 (get-in result [deletion-queue-key object-id1])))
(t/is (nil? (get-in result [deletion-queue-key object-id2])))))
(t/deftest clear-thumbnail-accumulates-in-queue (t/deftest clear-thumbnail-accumulates-in-queue
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri1 "blob:http://localhost/thumb-1" uri1 "blob:http://localhost/thumb-1"
uri2 "blob:http://localhost/thumb-2" uri2 "blob:http://localhost/thumb-2"
event1 (thumbnails/clear-thumbnail file-id object-id1) event1 (thumbnails/clear-thumbnail file-id object-id1)
event2 (thumbnails/clear-thumbnail file-id object-id2) event2 (thumbnails/clear-thumbnail file-id object-id2)
state {:thumbnails {object-id1 uri1 object-id2 uri2}} state {:thumbnails {object-id1 {:uri uri1 :rendered-at nil}
state1 (ptk/update event1 state) object-id2 {:uri uri2 :rendered-at nil}}}
state2 (ptk/update event2 state1)] state1 (ptk/update event1 state)
;; Both removed from thumbnails state2 (ptk/update event2 state1)]
(t/is (nil? (get-in state2 [:thumbnails object-id1]))) ;; Both removed from thumbnails
(t/is (nil? (get-in state2 [:thumbnails object-id2]))) (t/is (nil? (get-in state2 [:thumbnails object-id1])))
;; Both accumulated in the queue (t/is (nil? (get-in state2 [:thumbnails object-id2])))
(t/is (= uri1 (get-in state2 [deletion-queue-key object-id1]))) ;; Both accumulated in the queue
(t/is (= uri2 (get-in state2 [deletion-queue-key object-id2]))) (t/is (= uri1 (get-in state2 [deletion-queue-key object-id1])))
(t/is (= 2 (count (get state2 deletion-queue-key)))))) (t/is (= uri2 (get-in state2 [deletion-queue-key object-id2])))
(t/is (= 2 (count (get state2 deletion-queue-key))))))
(t/deftest remove-from-deletion-queue-removes-entry (t/deftest remove-from-deletion-queue-removes-entry
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
event (thumbnails/remove-from-deletion-queue object-id) event (thumbnails/remove-from-deletion-queue object-id)
state {deletion-queue-key {object-id "blob:http://localhost/thumb"}} state {deletion-queue-key {object-id "blob:http://localhost/thumb"}}
result (ptk/update event state)] result (ptk/update event state)]
(t/is (nil? (get-in result [deletion-queue-key object-id]))) (t/is (nil? (get-in result [deletion-queue-key object-id])))
(t/is (empty? (get result deletion-queue-key))))) (t/is (empty? (get result deletion-queue-key)))))
(t/deftest remove-from-deletion-queue-keeps-other-entries (t/deftest remove-from-deletion-queue-keeps-other-entries
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri1 "blob:http://localhost/thumb-1" uri1 "blob:http://localhost/thumb-1"
uri2 "blob:http://localhost/thumb-2" uri2 "blob:http://localhost/thumb-2"
event (thumbnails/remove-from-deletion-queue object-id1) event (thumbnails/remove-from-deletion-queue object-id1)
state {deletion-queue-key {object-id1 uri1 state {deletion-queue-key {object-id1 uri1
object-id2 uri2}} object-id2 uri2}}
result (ptk/update event state)] result (ptk/update event state)]
;; Only the specified entry is removed ;; Only the specified entry is removed
(t/is (nil? (get-in result [deletion-queue-key object-id1]))) (t/is (nil? (get-in result [deletion-queue-key object-id1])))
(t/is (= uri2 (get-in result [deletion-queue-key object-id2]))) (t/is (= uri2 (get-in result [deletion-queue-key object-id2])))
(t/is (= 1 (count (get result deletion-queue-key)))))) (t/is (= 1 (count (get result deletion-queue-key))))))
(t/deftest remove-before-clear-cancels-pending-delete (t/deftest remove-before-clear-cancels-pending-delete
(let [file-id (uuid/next)
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/thumb"
;; Step 1: clear-thumbnail queues the delete
state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id)
{:thumbnails {object-id {:uri uri :rendered-at nil}}})
;; Step 2: remove-from-deletion-queue cancels the pending delete
state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id)
state1)]
;; Thumbnail was removed from :thumbnails map by clear-thumbnail
(t/is (nil? (get-in state2 [:thumbnails object-id])))
;; But the deletion queue entry was cancelled by remove-from-deletion-queue
(t/is (nil? (get-in state2 [deletion-queue-key object-id])))
(t/is (empty? (get state2 deletion-queue-key)))))
(t/deftest clear-thumbnail-batch-drains-queue
(let [file-id (uuid/next)
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri1 "blob:http://localhost/thumb-1"
uri2 "blob:http://localhost/thumb-2"
;; Build up the queue state manually (simulating accumulated clear-thumbnails)
state {deletion-queue-key {object-id1 uri1 object-id2 uri2}}
event (#'thumbnails/clear-thumbnail-batch)
result (ptk/update event state)]
;; The queue is drained from application state
(t/is (empty? (get result deletion-queue-key)))))
(t/deftest clear-thumbnail-batch-empty-queue-noop
(let [state {deletion-queue-key {}}
event (#'thumbnails/clear-thumbnail-batch)
result (ptk/update event state)]
;; Queue key removed from state; rest of state unchanged
(t/is (empty? (get result deletion-queue-key)))
(t/is (= (dissoc state deletion-queue-key) (dissoc result deletion-queue-key)))))
(t/deftest assoc-thumbnail-adds-to-map
(let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/new-thumb"
event (#'thumbnails/assoc-thumbnail object-id uri)
state {:thumbnails {}}
result (ptk/update event state)]
(t/is (= uri (get-in result [:thumbnails object-id :uri])))
(t/is (some? (get-in result [:thumbnails object-id :rendered-at])))))
(t/deftest duplicate-thumbnail-copies-entry
(let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/dup-thumb"
entry {:uri uri :rendered-at nil}
event (thumbnails/duplicate-thumbnail old-id new-id)
state {:thumbnails {old-id entry}}
result (ptk/update event state)]
(t/is (= entry (get-in result [:thumbnails old-id])))
(t/is (= entry (get-in result [:thumbnails new-id])))))
;; --- Async WatchEvent tests ---
(defn- make-obj-ids
"Helper to create n properly-formatted object-ids for a single file."
[file-id n]
(vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame"))))
(t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids
(t/async
done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 3)
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{rp/cmd! mock/rpc-cmd-mock
tm/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= 1 (count @mock/rpc-calls)))
(let [[{:keys [cmd params]}] @mock/rpc-calls]
(t/is (= :delete-file-object-thumbnails cmd))
(t/is (= (vec oids) (:object-ids params))))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-partitions-large-batch
(t/async
done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 250)
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{rp/cmd! mock/rpc-cmd-mock
tm/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= 2 (count @mock/rpc-calls)))
(let [[c1 c2] @mock/rpc-calls]
(t/is (= :delete-file-object-thumbnails (:cmd c1)))
(t/is (= :delete-file-object-thumbnails (:cmd c2)))
(t/is (= 200 (count (:object-ids (:params c1)))))
(t/is (= 50 (count (:object-ids (:params c2)))))
(t/is (= (set oids)
(set (concat (:object-ids (:params c1))
(:object-ids (:params c2)))))))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-revokes-blob-uris
(t/async
done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 2)
uris ["blob:http://localhost/thumb-1"
"blob:http://localhost/thumb-2"]
state {deletion-queue-key (zipmap oids uris)}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{rp/cmd! mock/rpc-cmd-mock
wapi/revoke-uri mock/revoke-uri-mock
tm/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= (set uris) (set @mock/revoked-uris)))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc
(t/async
done
(let [event (#'thumbnails/clear-thumbnail-batch)
state {}]
(ptk/update event state)
(mock/with-mocks
{rp/cmd! mock/rpc-cmd-mock
tm/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (empty? @mock/rpc-calls))
(done')))))
done))))
(t/deftest clear-thumbnail-watch-emits-batch-after-debounce
(t/async
done
(let [file-id (uuid/next) (let [file-id (uuid/next)
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/thumb" uri "blob:http://localhost/thumb"
;; Step 1: clear-thumbnail queues the delete state {:thumbnails {object-id {:uri uri :rendered-at nil}}}
state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id) event (thumbnails/clear-thumbnail file-id object-id)]
{:thumbnails {object-id uri}}) (ptk/update event state)
;; Step 2: remove-from-deletion-queue cancels the pending delete (mock/with-mocks
state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id) {rx/timer mock/timer-mock}
state1)] (fn [done']
;; Thumbnail was removed from :thumbnails map by clear-thumbnail (let [stream (rx/subject)]
(t/is (nil? (get-in state2 [:thumbnails object-id]))) (->> (ptk/watch event state stream)
;; But the deletion queue entry was cancelled by remove-from-deletion-queue
(t/is (nil? (get-in state2 [deletion-queue-key object-id])))
(t/is (empty? (get state2 deletion-queue-key)))))
(t/deftest clear-thumbnail-batch-drains-queue
(let [file-id (uuid/next)
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri1 "blob:http://localhost/thumb-1"
uri2 "blob:http://localhost/thumb-2"
;; Build up the queue state manually (simulating accumulated clear-thumbnails)
state {deletion-queue-key {object-id1 uri1 object-id2 uri2}}
event (#'thumbnails/clear-thumbnail-batch)
result (ptk/update event state)]
;; The queue is drained from application state
(t/is (empty? (get result deletion-queue-key)))))
(t/deftest clear-thumbnail-batch-empty-queue-noop
(let [state {deletion-queue-key {}}
event (#'thumbnails/clear-thumbnail-batch)
result (ptk/update event state)]
;; State unchanged when queue is already empty
(t/is (empty? (get result deletion-queue-key)))
(t/is (= state (dissoc result deletion-queue-key)))))
(t/deftest assoc-thumbnail-adds-to-map
(let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/new-thumb"
event (#'thumbnails/assoc-thumbnail object-id uri)
state {:thumbnails {}}
result (ptk/update event state)]
(t/is (= uri (get-in result [:thumbnails object-id])))))
(t/deftest duplicate-thumbnail-copies-entry
(let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/dup-thumb"
event (thumbnails/duplicate-thumbnail old-id new-id)
state {:thumbnails {old-id uri}}
result (ptk/update event state)]
(t/is (= uri (get-in result [:thumbnails old-id])))
(t/is (= uri (get-in result [:thumbnails new-id])))))
;; --- Async WatchEvent tests ---
(defn- make-obj-ids
"Helper to create n properly-formatted object-ids for a single file."
[file-id n]
(vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame"))))
(t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids
(t/async done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 3)
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{app.main.repo/cmd! mock/rpc-cmd-mock
app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj []) (rx/reduce conj [])
(rx/subs! (rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= 1 (count @mock/rpc-calls)))
(let [[{:keys [cmd params]}] @mock/rpc-calls]
(t/is (= :delete-file-object-thumbnails cmd))
(t/is (= (vec oids) (:object-ids params))))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-partitions-large-batch
(t/async done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 250)
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{app.main.repo/cmd! mock/rpc-cmd-mock
app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= 2 (count @mock/rpc-calls)))
(let [[c1 c2] @mock/rpc-calls]
(t/is (= :delete-file-object-thumbnails (:cmd c1)))
(t/is (= :delete-file-object-thumbnails (:cmd c2)))
(t/is (= 200 (count (:object-ids (:params c1)))))
(t/is (= 50 (count (:object-ids (:params c2)))))
(t/is (= (set oids)
(set (concat (:object-ids (:params c1))
(:object-ids (:params c2)))))))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-revokes-blob-uris
(t/async done
(let [file-id (uuid/next)
oids (make-obj-ids file-id 2)
uris ["blob:http://localhost/thumb-1"
"blob:http://localhost/thumb-2"]
state {deletion-queue-key (zipmap oids uris)}
event (#'thumbnails/clear-thumbnail-batch)]
(ptk/update event state)
(mock/with-mocks
{app.main.repo/cmd! (fn [_ _] (rx/of nil))
app.util.webapi/revoke-uri mock/revoke-uri-mock
app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (= (set uris) (set @mock/revoked-uris)))
(done')))))
done))))
(t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc
(t/async done
(let [event (#'thumbnails/clear-thumbnail-batch)
state {}]
(ptk/update event state)
(mock/with-mocks
{app.main.repo/cmd! mock/rpc-cmd-mock
app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [_]
(t/is (empty? @mock/rpc-calls))
(done')))))
done))))
(t/deftest clear-thumbnail-watch-emits-batch-after-debounce
(t/async done
(let [file-id (uuid/next)
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
uri "blob:http://localhost/thumb"
state {:thumbnails {object-id uri}}
event (thumbnails/clear-thumbnail file-id object-id)]
(ptk/update event state)
(mock/with-mocks
{beicon.v2.core/timer mock/timer-mock}
(fn [done']
(->> (ptk/watch event state nil)
(rx/reduce conj [])
(rx/subs!
(fn [_] nil)
(fn [err] (t/is (nil? err)) (done'))
(fn [events] (fn [events]
(t/is (= 1 (count events))) (t/is (= 1 (count events)))
(t/is (ptk/event? (first events))) (t/is (ptk/event? (first events)))
(done'))))) (done'))
done))))) (fn [err] (t/is (nil? err)) (done'))
nil))))
done))))

View File

@ -106,9 +106,12 @@
(defn rpc-cmd-mock (defn rpc-cmd-mock
"Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`." "Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`."
[cmd params] ([cmd params]
(swap! rpc-calls conj {:cmd cmd :params params}) (swap! rpc-calls conj {:cmd cmd :params params})
(rx/of nil)) (rx/of nil))
([cmd params _opts]
(swap! rpc-calls conj {:cmd cmd :params params})
(rx/of nil)))
(defn revoke-uri-mock (defn revoke-uri-mock
"Records `uri` in [[revoked-uris]]." "Records `uri` in [[revoked-uris]]."
@ -117,8 +120,10 @@
(defn schedule-on-idle-mock (defn schedule-on-idle-mock
"Calls `f` immediately instead of deferring to the idle queue." "Calls `f` immediately instead of deferring to the idle queue."
[f] ([_ms f]
(f)) (f))
([f]
(f)))
(defn timer-mock (defn timer-mock
"Returns `(rx/of :immediate)` so debounce timers fire instantly "Returns `(rx/of :immediate)` so debounce timers fire instantly