diff --git a/src/clj/grub/core.clj b/src/clj/grub/core.clj index ab45f5f..8cbc156 100644 --- a/src/clj/grub/core.clj +++ b/src/clj/grub/core.clj @@ -56,14 +56,16 @@ :stop-server nil :states (atom nil)}) -(defn handle-websocket [handler states new-states] +(defn handle-websocket [handler states new-states-pub] (fn [{:keys [websocket?] :as request}] (if websocket? (httpkit/with-channel request ws-channel (let [to-client (chan) - from-client (chan)] + from-client (chan) + new-states (chan (a/sliding-buffer 1))] + (a/sub new-states-pub :new-state new-states) (ws/add-connected-client! ws-channel to-client from-client) - (sync/sync-new-client! to-client from-client new-states states))) + (sync/make-server-agent to-client from-client new-states states))) (handler request)))) (defn handle-root [handler index] @@ -80,20 +82,21 @@ :body ""} (handler req)))) -(defn make-handler [{:keys [index states]} new-states] +(defn make-handler [{:keys [index states]} new-states-pub] (-> (fn [req] "Not found") (file/wrap-file "public") (content-type/wrap-content-type) (handle-root index) - (handle-websocket states new-states) + (handle-websocket states new-states-pub) (wrap-bounce-favicon))) (defn start [{:keys [port db-name states] :as system}] (let [{:keys [db conn]} (db/connect db-name) new-states (chan) + new-states-pub (a/pub new-states (fn [_] :new-state)) db-state (db/get-current-state db) _ (reset! states (state/new-states (if db-state db-state state/empty-state))) - stop-server (httpkit/run-server (make-handler system new-states) {:port port})] + stop-server (httpkit/run-server (make-handler system new-states-pub) {:port port})] (add-watch states :db (fn [_ _ old new] (when-not (= old new) (let [new-state (state/get-latest new)] diff --git a/src/clj/grub/websocket.clj b/src/clj/grub/websocket.clj index 0f63862..f04c62f 100644 --- a/src/clj/grub/websocket.clj +++ b/src/clj/grub/websocket.clj @@ -18,8 +18,9 @@ (defn read-msg [msg] (let [in (ByteArrayInputStream. (.getBytes msg)) - reader (t/reader in :json)] - (t/read reader))) + reader (t/reader in :json) + received (t/read reader)] + received)) (defn add-connected-client! [ws-channel to from] (println "Client connected:" (.toString ws-channel)) diff --git a/src/cljs/grub/websocket.cljs b/src/cljs/grub/websocket.cljs index ad5e310..a98a876 100644 --- a/src/cljs/grub/websocket.cljs +++ b/src/cljs/grub/websocket.cljs @@ -22,7 +22,8 @@ (send-pending-msg websocket pending-msg)) (defn read-msg [msg] - (t/read reader (.-message msg))) + (let [received (t/read reader (.-message msg))] + received)) (defn connect [pending-msg in out] (let [ws (goog.net.WebSocket.) diff --git a/src/cljx/grub/diff.cljx b/src/cljx/grub/diff.cljx index 186325d..183641a 100644 --- a/src/cljx/grub/diff.cljx +++ b/src/cljx/grub/diff.cljx @@ -1,7 +1,6 @@ (ns grub.diff (:require [clojure.data :as data] - [clojure.set :as set] - [grub.tag :as tag])) + [clojure.set :as set])) (defn deleted [a b] (set/difference (into #{} (keys a)) (into #{} (keys b)))) @@ -22,27 +21,19 @@ (into {}))) (defn diff-states [prev next] - (let [key-diffs (diff-keys prev next)] - (if (and (:tag prev) (:tag next)) - (assoc key-diffs - :shadow-tag (:tag prev) - :tag (:tag next)) - key-diffs))) + (->> prev + (keys) + (map (fn [k] [k (diff-maps (k prev) (k next))])) + (filter #(not (nil? (second %)))) + (into {}))) (defn patch-map [state diff] (-> state (#(apply dissoc % (into [] (:- diff)))) (#(merge-with merge % (:+ diff))))) -(defn patch-state - ([state diff] (patch-state state diff false)) - ([state diff use-diff-tag?] - (let [patched (->> state - (keys) - (map (fn [k] [k (patch-map (k state) (k diff))])) - (into {}))] - (if use-diff-tag? - (assoc patched :tag (:tag diff)) - (if (= state patched) - state - (assoc patched :tag (tag/new-tag))))))) +(defn patch-state [state diff] + (->> state + (keys) + (map (fn [k] [k (patch-map (k state) (k diff))])) + (into {}))) diff --git a/src/cljx/grub/state.cljx b/src/cljx/grub/state.cljx index 94871f6..e4142c9 100644 --- a/src/cljx/grub/state.cljx +++ b/src/cljx/grub/state.cljx @@ -1,14 +1,13 @@ (ns grub.state (:require [grub.diff :as diff] - [grub.util :as util] - [grub.tag :as tag])) + [grub.util :as util])) (def num-history-states 20) -(def empty-state {:tag (tag/oldest-tag) :grubs {} :recipes {}}) +(def empty-state {:tag 0 :grubs {} :recipes {}}) (defn new-states [state] - [(assoc state :tag (tag/new-tag))]) + [(assoc state :tag 0)]) (defn get-latest [states] (last states)) @@ -22,7 +21,7 @@ (let [last-state (last states)] (if (= last-state new-state) states - (let [new-states (conj states (assoc new-state :tag (tag/new-tag)))] + (let [new-states (conj states (assoc new-state :tag (inc (:tag last-state))))] (if (>= (count states) num-history-states) (into [] (rest new-states)) new-states))))) diff --git a/src/cljx/grub/sync.cljx b/src/cljx/grub/sync.cljx index 35a182a..dfb2bf5 100644 --- a/src/cljx/grub/sync.cljx +++ b/src/cljx/grub/sync.cljx @@ -22,24 +22,27 @@ (defn diff-msg [shadow state] (let [diff (diff/diff-states shadow state)] {:type :diff - :diff diff})) + :diff diff + :tag (:tag state) + :shadow-tag (:tag shadow)})) -(defmulti handle-event (fn [event] - (:type event))) +(defmulti handle-event (fn [event] (:type event))) -(defn apply-diff [states diff shadow] +(defn apply-diff [states diff shadow new-shadow-tag client?] (let [new-states (swap! states update-states diff) new-state (state/get-latest new-states) - new-shadow (diff/patch-state shadow diff true)] - {:out-event (when-not (state/state= shadow new-state) - (diff-msg new-shadow new-state)) - :new-states new-states - :new-shadow new-shadow})) + new-shadow (assoc (diff/patch-state shadow diff) + :tag new-shadow-tag)] + {:new-shadow new-shadow + ;; Workaround to send an "ACK" diff when there are no changes + :out-event (when (and (not client?) + (state/state= new-state new-shadow)) + (diff-msg new-shadow new-state))})) -(defmethod handle-event :diff [{:keys [diff states shadow client?]}] - (let [history-shadow (state/get-tagged @states (:shadow-tag diff))] +(defmethod handle-event :diff [{:keys [diff states shadow shadow-tag tag client?]}] + (let [history-shadow (state/get-tagged @states shadow-tag)] (if history-shadow - (apply-diff states diff history-shadow) + (apply-diff states diff history-shadow tag client?) (if client? {:out-event full-sync-request :new-shadow shadow} @@ -56,45 +59,79 @@ (reset! states (state/new-states full-state)) {:new-shadow full-state}) +(defmethod handle-event :new-state [{:keys [shadow states new-state client?]}] + (let [new-states (swap! states state/add new-state) + latest-state (state/get-latest new-states)] + {:out-event (when-not (state/state= shadow latest-state) + (diff-msg shadow latest-state)) + :new-shadow (when (and (not client?) + (not (state/state= shadow latest-state))) + (assoc latest-state :tag (inc (:tag shadow))))})) + (defmethod handle-event :default [msg] #+cljs (logs "Unhandled message:" msg) #+clj (println "Unhandled message:" msg) {}) -(defn make-agent - ([client? >remote events new-states states] - (make-agent client? >remote events new-states states state/empty-state)) - ([client? >remote events new-states states initial-shadow] +(defn make-server-agent + ([>remote events new-states states] + (make-server-agent >remote events new-states states state/empty-state)) + ([>remote events new-states states initial-shadow] (go (loop [shadow initial-shadow] (let [[v c] (a/alts! [new-states events] :priority true)] (cond (nil? v) nil ;; drop out of loop (= c new-states) - (do (when-not (state/state= shadow v) - (swap! states state/add v) - (>! >remote (diff-msg shadow v))) - (recur shadow)) + (let [event {:type :new-state + :new-state v + :shadow shadow + :states states + :client? false} + {:keys [out-event new-shadow]} (handle-event event)] + (when out-event (a/put! >remote out-event)) + (recur (if new-shadow new-shadow shadow))) (= c events) (let [event (assoc v :states states - :client? client? + :client? false :shadow shadow) {:keys [new-shadow out-event]} (handle-event event)] (when out-event (a/put! >remote out-event)) (recur (if new-shadow new-shadow shadow))))))))) -(def make-server-agent (partial make-agent false)) -(def make-client-agent (partial make-agent true)) - -#+clj -(defn sync-new-client! [>remote events new-states states] - (make-server-agent >remote events new-states states)) +(defn make-client-agent + ([>remote events new-states states] + (make-client-agent >remote events new-states states state/empty-state)) + ([>remote events new-states states initial-shadow] + (go (loop [shadow initial-shadow + out-event nil] + (when out-event (>! >remote out-event)) + (let [timeout (a/timeout 1000) + [v c] (if out-event + (a/alts! [events timeout]) + (a/alts! [new-states events] :priority true))] + (cond (= c timeout) (recur shadow out-event) + (nil? v) nil ;; drop out of loop + (= c new-states) + (let [event {:type :new-state + :new-state v + :shadow shadow + :states states + :client? true} + {:keys [out-event]} (handle-event event)] + (recur shadow out-event)) + (= c events) + (let [event (assoc v + :states states + :client? true + :shadow shadow) + {:keys [new-shadow out-event]} (handle-event event)] + (recur (if new-shadow new-shadow shadow) out-event)))))))) #+cljs (defn sync-client! [>remote events new-states states] - (let [new-states* (chan)] + (let [new-states* (chan (a/sliding-buffer 1))] (go (loop [] (let [v (! new-states* v) (recur)))) (make-client-agent >remote events new-states* states) diff --git a/src/cljx/grub/tag.cljx b/src/cljx/grub/tag.cljx deleted file mode 100644 index c08c263..0000000 --- a/src/cljx/grub/tag.cljx +++ /dev/null @@ -1,9 +0,0 @@ -(ns grub.tag) - -(defn new-tag [] - #+clj (java.util.Date.) - #+cljs (js/Date.)) - -(defn oldest-tag [] - #+clj (java.util.Date. 0) - #+cljs (js/Date. 0)) diff --git a/src/test/grub/test/unit/diff.clj b/src/test/grub/test/unit/diff.clj index c7ec636..3a96b46 100644 --- a/src/test/grub/test/unit/diff.clj +++ b/src/test/grub/test/unit/diff.clj @@ -130,37 +130,6 @@ (fact "Diff of many changes has all changes" (diff/diff-states before-state after-state) => expected-diff) -(fact "Diff and patch of many changes returns original state with new tag" - (let [diff (diff/diff-states before-state after-state) - result (diff/patch-state before-state diff)] - (dissoc result :tag) => after-state - (:tag result) => #(not (nil? %)))) - -(fact "Diff of states with tags includes tags in diff" - (diff/diff-states {:tag "1"} {:tag "2"}) => {:tag "2" :shadow-tag "1"}) - -(fact "Patch of state creates new tag by default" - (let [result (diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0} {:grubs {:+ {:a {:b1 :b3}}} :- #{}})] - result => (contains {:grubs {:a {:b1 :b3}}}) - (:tag result) => #(not (nil? %)) - (:tag result) => #(not= % 0))) - -(fact "Patch of state sets new tag to patch tag if specified" - (diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0} - {:grubs {:+ {:a {:b1 :b3}}} :- #{} :tag 4} - true) - => - {:grubs {:a {:b1 :b3}} :tag 4}) - -(fact "Empty patch of state sets new tag to patch tag if specified" - (diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0} - {:shadow-tag 0 :tag 4 :grubs {:+ nil :- #{}}} - true) - => - {:grubs {:a {:b1 :b2}} :tag 4}) - -(fact "Patch of empty diff returns original state" - (diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0} - {:grubs {:+ nil :- #{}} :tag 4}) - => - {:grubs {:a {:b1 :b2}} :tag 0}) +(fact "Diff and patch of many changes returns original state" + (let [diff (diff/diff-states before-state after-state)] + (diff/patch-state before-state diff) => after-state)) diff --git a/src/test/grub/test/unit/state.clj b/src/test/grub/test/unit/state.clj index 6b90366..0b2b618 100644 --- a/src/test/grub/test/unit/state.clj +++ b/src/test/grub/test/unit/state.clj @@ -3,34 +3,33 @@ [midje.sweet :refer :all])) (fact "Get current state returns last state" - (let [states [{:tag "1" :a :b} - {:tag "2" :c :d}]] - (s/get-latest states) => {:tag "2" :c :d})) + (let [states [{:tag 1 :a :b} + {:tag 2 :c :d}]] + (s/get-latest states) => {:tag 2 :c :d})) (fact "Get history state returns state with given hash" - (let [states [{:tag "1" :a :b} - {:tag "2" :c :d} - {:tag "3" :e :f}]] - (s/get-tagged states "1") => {:tag "1" :a :b} - (s/get-tagged states "2") => {:tag "2" :c :d} - (s/get-tagged states "3") => {:tag "3" :e :f})) + (let [states [{:tag 1 :a :b} + {:tag 2 :c :d} + {:tag 3 :e :f}]] + (s/get-tagged states 1) => {:tag 1 :a :b} + (s/get-tagged states 2) => {:tag 2 :c :d} + (s/get-tagged states 3) => {:tag 3 :e :f})) -(fact "Add history state appends state to the end" - (let [states [{:tag "1" :a :b} - {:tag "2" :c :d}]] - (-> (s/add states {:e :f}) - (last) - (dissoc :tag)) - => {:e :f})) +(fact "Add history state appends state to the end and increments tag" + (let [states [{:tag 1 :a :b} + {:tag 2 :c :d}]] + (s/add states {:e :f}) => [{:tag 1 :a :b} + {:tag 2 :c :d} + {:tag 3 :e :f}])) (fact "Add history state appends state to the end and drops first state if full" - (let [states (into [] (for [i (range 20)] {:tag (str i) :i i})) + (let [states (into [] (for [i (range 20)] {:tag i :i i})) new-states (s/add states {:i 21})] (count new-states) => 20 (dissoc (last new-states) :tag) => {:i 21} - (first new-states) => {:tag "1" :i 1})) + (first new-states) => {:tag 1 :i 1})) (fact "Add history state does not add consecutive duplicate states" - (let [states [{:tag "1" :a :b} - {:tag "2" :c :d}]] - (s/add states {:tag "2" :c :d}) => states)) + (let [states [{:tag 1 :a :b} + {:tag 2 :c :d}]] + (s/add states {:tag 2 :c :d}) => states)) diff --git a/src/test/grub/test/unit/sync.clj b/src/test/grub/test/unit/sync.clj index 1e5a14c..8e3e0c1 100644 --- a/src/test/grub/test/unit/sync.clj +++ b/src/test/grub/test/unit/sync.clj @@ -3,150 +3,159 @@ [grub.sync :as sync] [midje.sweet :refer :all])) -(facts "Server diff" - (fact "Server applies diff, returns empty diff with client tag, new server tag when no server changes" - (let [{:keys [new-states out-event new-shadow]} +(facts "Server" + (fact "Diff, no server changes - Apply diff, return empty diff" + (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) + {:keys [out-event new-shadow]} (sync/handle-event {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) + :tag 4 + :shadow-tag 0 + :diff {:grubs {:+ {"1" {:completed true}} :- #{}}} + :states states :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :client? false})] - new-states => (just (just {:tag 0 - :grubs {"1" {:completed false, :text "2 apples"}} - :recipes {}}) - (just {:tag #(not (nil? %)) - :grubs {"1" {:completed true, :text "2 apples"}} - :recipes {}})) + @states => (just (just {:tag 0 + :grubs {"1" {:completed false, :text "2 apples"}} + :recipes {}}) + (just {:tag 1 + :grubs {"1" {:completed true, :text "2 apples"}} + :recipes {}})) out-event => (just {:type :diff - :diff (just {:shadow-tag 4 - :tag #(not (nil? %)) - :grubs {:- #{} :+ nil} - :recipes {:- #{}, :+ nil}})}) + :diff {:grubs {:- #{} :+ nil} :recipes {:- #{}, :+ nil}} + :shadow-tag 4 + :tag 1}) new-shadow => {:tag 4 :grubs {"1" {:completed true, :text "2 apples"}} :recipes {}})) - (fact "Server applies diff and returns changes when server has changed" - (let [{:keys [new-states new-shadow out-event]} + (fact "Diff, server changes - Apply diff, return changes" + (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} + {:tag 1 :grubs {"1" {:text "2 apples" :completed false} + "2" {:text "3 onions" :completed false}} + :recipes {}}]) + {:keys [new-shadow out-event]} (sync/handle-event {:type :diff - :diff {:shadow-tag 0 :tag 4 - :grubs {:+ {"1" {:completed true}} :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} - {:tag 1 :grubs {"1" {:text "2 apples" :completed false} - "2" {:text "3 onions" :completed false}} - :recipes {}}]) + :shadow-tag 0 + :tag 4 + :diff {:grubs {:+ {"1" {:completed true}} :- #{}}} + :states states :shadow state/empty-state :client? false})] - new-states => + @states => (just {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} {:tag 1 :grubs {"1" {:text "2 apples" :completed false} - "2" {:text "3 onions" :completed false}} - :recipes {}} - (just {:tag #(not (nil? %)) :grubs {"1" {:text "2 apples" :completed true} - "2" {:text "3 onions" :completed false}} - :recipes {}})) + "2" {:text "3 onions" :completed false}} :recipes {}} + {:tag 2 :grubs {"1" {:text "2 apples" :completed true} + "2" {:text "3 onions" :completed false}} :recipes {}}) out-event => (just {:type :diff - :diff (just {:shadow-tag 4 - :tag #(not (nil? %)) - :grubs {:- #{} :+ {"2" {:completed false, :text "3 onions"}}} - :recipes {:- #{}, :+ nil}})}) + :shadow-tag 4 + :tag 2 + :diff {:grubs {:- #{} :+ {"2" {:completed false, :text "3 onions"}}} + :recipes {:- #{}, :+ nil}}}) new-shadow => {:tag 4 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}})) - (fact "Server forces full sync if client is out of sync" + (fact "Diff, client out of sync - Force full sync" (let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} {:tag 15 :grubs {"1" {:text "2 apples" :completed false} "2" {:text "3 onions" :completed false}} :recipes {}}]) event {:type :diff - :diff {:shadow-tag 3 :tag 12 - :grubs {:updated {"0" {:completed true}} :deleted #{}}} + :shadow-tag 3 + :tag 12 + :diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}} :states states :shadow state/empty-state :client? false} - {:keys [new-states new-shadow out-event]} (sync/handle-event event)] - new-states => nil + {:keys [new-shadow out-event]} (sync/handle-event event)] out-event => {:type :full-sync :full-state {:tag 15 :grubs {"1" {:text "2 apples" :completed false} "2" {:text "3 onions" :completed false}} :recipes {}}})) - (fact "Server state is unchanged on receiving empty diff" - (-> (sync/handle-event - {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) - :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} - :client? false}) - :new-states) - => [{:tag 0 - :grubs {"1" {:completed false, :text "2 apples"}} - :recipes {}}]) + (fact "New state - Update state, send diff, update shadow assuming diff received" + (let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) + event {:type :new-state + :states states + :shadow {:tag 3 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} + :client? false + :new-state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}} + {:keys [new-shadow out-event]} (sync/handle-event event)] + @states => [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} + {:tag 15 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}] + new-shadow => {:tag 4 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}} + out-event => {:type :diff + :shadow-tag 3 + :tag 15 + :diff {:grubs {:+ {"1" {:completed true}} :- #{}} + :recipes {:+ nil :- #{}}}})) - (fact "Server returns no response on empty diff" - (-> (sync/handle-event - {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) - :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} - :client? false}) - :out-event) - => nil) + (fact "Server sends full sync if client requests it" + (let [result (sync/handle-event + {:type :full-sync-request + :states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} + {:tag 15 :grubs {"1" {:text "2 apples" :completed false} + "2" {:text "3 onions" :completed false}} + :recipes {}}])})] + (:new-shadow result) => + (just {:tag #(not (nil? %)) + :grubs {"1" {:text "2 apples" :completed false} + "2" {:text "3 onions" :completed false}} + :recipes {}}) + (:out-event result) => + {:type :full-sync + :full-state {:tag 15 + :grubs {"1" {:text "2 apples" :completed false} + "2" {:text "3 onions" :completed false}} + :recipes {}}})) + ) - (fact "Server updates client shadow on empty diff" - (-> (sync/handle-event - {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) - :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} - :client? false}) - :new-shadow) - => {:tag 4 :grubs {"1" {:completed false, :text "2 apples"}} :recipes {}})) (facts "Client diffs" - (fact "Client applies diff, returns empty diff with server tag, new client tag when no client changes" - (let [event {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}} - :states (atom - [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} - :recipes {}}]) + (fact "Client applies diff, does not return diff when no client changes" + (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) + event {:type :diff + :shadow-tag 0 + :tag 4 + :diff {:grubs {:+ {"1" {:completed true}} :- #{}}} + :states states :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :client? true} - {:keys [new-states new-shadow out-event]} (sync/handle-event event)] - new-states => + {:keys [new-shadow out-event]} (sync/handle-event event)] + @states => (just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}} - (just {:tag #(not (nil? %)) - :grubs {"1" {:completed true, :text "2 apples"}} - :recipes {}})) + {:tag 1 + :grubs {"1" {:completed true, :text "2 apples"}} + :recipes {}}) new-shadow {:tag 4 :grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}} - out-event => (just {:type :diff - :diff (just {:shadow-tag 4 - :tag #(not (nil? %)) - :grubs {:- #{} :+ nil} - :recipes {:- #{}, :+ nil}})}))) + out-event => nil)) (fact "Client state is unchanged on receiving empty diff" - (-> (sync/handle-event - {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} - :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) - :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} - :client? true}) - :new-states) - => [{:tag 0 - :grubs {"1" {:completed false, :text "2 apples"}} - :recipes {}}]) + (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])] + (sync/handle-event + {:type :diff + :shadow-tag 0 + :tag 4 + :diff {:grubs {:+ nil :- #{}}} + :states states + :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} + :client? true}) + @states => [{:tag 0 + :grubs {"1" {:completed false, :text "2 apples"}} + :recipes {}}])) (fact "Client returns no response on empty diff" (-> (sync/handle-event {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} + :shadow-tag 0 + :tag 4 + :diff {:grubs {:+ nil :- #{}}} :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :client? true}) @@ -156,7 +165,9 @@ (fact "Client updates server shadow on empty diff" (-> (sync/handle-event {:type :diff - :diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} + :shadow-tag 0 + :tag 4 + :diff {:grubs {:+ nil :- #{}}} :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :client? true}) @@ -171,7 +182,6 @@ {:tag 15 :grubs {"1" {:text "2 apples" :completed false} "2" {:text "3 onions" :completed false}} :recipes {}}])})] - (:new-states result) => nil (:new-shadow result) => (just {:tag #(not (nil? %)) :grubs {"1" {:text "2 apples" :completed false}