Use timestamp tags instead of hashes

- Hashing was slow and we weren't using the hash properties much anyway
This commit is contained in:
Nicholas Kariniemi 2014-10-19 21:42:49 +03:00
parent 19ca650078
commit 8fe22b9a52
13 changed files with 385 additions and 269 deletions

View file

@ -18,7 +18,8 @@
(defn start (defn start
"Starts the current development system." "Starts the current development system."
[] []
(alter-var-root #'system system/start system/dev-system)) (alter-var-root #'system (constantly system/dev-system))
(alter-var-root #'system system/start))
(defn stop (defn stop
"Shuts down and destroys the current development system." "Shuts down and destroys the current development system."

View file

@ -17,7 +17,6 @@
[clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]] [clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]]
[sablono "0.2.17"] [sablono "0.2.17"]
[cljs-uuid "0.0.4"] [cljs-uuid "0.0.4"]
[net.polyc0l0r/hasch "0.2.3"]
[com.cognitect/transit-clj "0.8.259"] [com.cognitect/transit-clj "0.8.259"]
[com.cognitect/transit-cljs "0.8.188"]] [com.cognitect/transit-cljs "0.8.188"]]
:profiles {:uberjar {:aot :all} :profiles {:uberjar {:aot :all}

View file

@ -92,11 +92,11 @@
(let [{:keys [db conn]} (db/connect db-name) (let [{:keys [db conn]} (db/connect db-name)
new-states (chan) new-states (chan)
db-state (db/get-current-state db) db-state (db/get-current-state db)
_ (reset! states (state/new-state (if db-state db-state (state/empty-state)))) _ (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) {:port port})]
(add-watch states :db (fn [_ _ old new] (add-watch states :db (fn [_ _ old new]
(when-not (= old new) (when-not (= old new)
(let [new-state (state/get-current-state new)] (let [new-state (state/get-latest new)]
(a/put! new-states new-state) (a/put! new-states new-state)
(db/update-db! db new-state))))) (db/update-db! db new-state)))))
(println "Started server on localhost:" port) (println "Started server on localhost:" port)
@ -106,6 +106,7 @@
:stop-server stop-server :stop-server stop-server
:states states))) :states states)))
(defn stop [{:keys [db-conn stop-server states] :as system}] (defn stop [{:keys [db-conn stop-server states] :as system}]
(remove-watch states :db) (remove-watch states :db)
(stop-server) (stop-server)

View file

@ -27,7 +27,7 @@
agent-states (sync/sync-client! >remote events new-states states)] agent-states (sync/sync-client! >remote events new-states states)]
(add-watch states :render (fn [_ _ old new] (add-watch states :render (fn [_ _ old new]
(when-not (= old new) (when-not (= old new)
(a/put! render-states (state/get-current-state new))))) (a/put! render-states (state/get-latest new)))))
(assoc system (assoc system
:ws ws :ws ws
:channels {:new-states new-states :channels {:new-states new-states

View file

@ -1,6 +1,7 @@
(ns grub.diff (ns grub.diff
(:require [clojure.data :as data] (:require [clojure.data :as data]
[clojure.set :as set])) [clojure.set :as set]
[grub.tag :as tag]))
(defn deleted [a b] (defn deleted [a b]
(set/difference (into #{} (keys a)) (into #{} (keys b)))) (set/difference (into #{} (keys a)) (into #{} (keys b))))
@ -9,22 +10,39 @@
(second (data/diff a b))) (second (data/diff a b)))
(defn diff-maps [a b] (defn diff-maps [a b]
{:deleted (deleted a b) (when (and (map? a) (map? b))
:updated (updated a b)}) {:- (deleted a b)
:+ (updated a b)}))
(defn diff-states [prev next] (defn diff-keys [prev next]
(->> prev (->> prev
(keys) (keys)
(map (fn [k] [k (diff-maps (k prev) (k next))])) (map (fn [k] [k (diff-maps (k prev) (k next))]))
(filter #(not (nil? (second %))))
(into {}))) (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)))
(defn patch-map [state diff] (defn patch-map [state diff]
(-> state (-> state
(#(apply dissoc % (into [] (:deleted diff)))) (#(apply dissoc % (into [] (:- diff))))
(#(merge-with merge % (:updated diff))))) (#(merge-with merge % (:+ diff)))))
(defn patch-state [state diff] (defn patch-state
(->> state ([state diff] (patch-state state diff false))
(keys) ([state diff use-diff-tag?]
(map (fn [k] [k (patch-map (k state) (k diff))])) (let [patched (->> state
(into {}))) (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)))))))

View file

@ -1,12 +0,0 @@
(ns grub.message)
(def full-sync-request {:type :full-sync-request})
(defn full-sync [state]
{:type :full-sync
:full-state state})
(defn diff-msg [diff hash]
{:type :diff
:diff diff
:hash hash})

View file

@ -1,32 +1,31 @@
(ns grub.state (ns grub.state
(:require [grub.diff :as diff] (:require [grub.diff :as diff]
[grub.util :as util] [grub.util :as util]
[hasch.core :as hasch])) [grub.tag :as tag]))
(def num-history-states 20) (def num-history-states 20)
(def empty-state {:grubs {} :recipes {}}) (def empty-state {:tag (tag/oldest-tag) :grubs {} :recipes {}})
(def empty-states [{:grubs {} :recipes {}}])
(defn new-state [state] (defn new-states [state]
[{:hash (hasch/uuid state) [(assoc state :tag (tag/new-tag))])
:state state}])
(defn get-current-state [states] (defn get-latest [states]
(:state (last states))) (last states))
(defn get-history-state [states hash] (defn get-tagged [states tag]
(:state (first (filter #(= (:hash %) hash) states)))) (->> states
(filter #(= (:tag %) tag))
(first)))
(defn add-history-state [states new-state] (defn add [states new-state]
(let [last-hash (:hash (last states)) (let [last-state (last states)]
new-hash (hasch/uuid new-state)] (if (= last-state new-state)
(if (= last-hash new-hash)
states states
(let [new-states (conj states {:hash new-hash :state new-state})] (let [new-states (conj states (assoc new-state :tag (tag/new-tag)))]
(if (>= (count states) num-history-states) (if (>= (count states) num-history-states)
(into [] (rest new-states)) (into [] (rest new-states))
new-states))))) new-states)))))
(defn empty-diff? [diff] (defn state= [a b]
(= diff {:recipes {:deleted #{}, :updated nil}, :grubs {:deleted #{}, :updated nil}})) (= (dissoc a :tag) (dissoc b :tag)))

View file

@ -1,51 +1,55 @@
(ns grub.sync (ns grub.sync
(:require [grub.diff :as diff] (:require [grub.diff :as diff]
[grub.message :as message]
[grub.state :as state] [grub.state :as state]
[hasch.core :as hasch]
#+clj [clojure.core.async :as a :refer [<! >! chan go]] #+clj [clojure.core.async :as a :refer [<! >! chan go]]
#+cljs [cljs.core.async :as a :refer [<! >! chan]]) #+cljs [cljs.core.async :as a :refer [<! >! chan]])
#+cljs (:require-macros [grub.macros :refer [log logs]] #+cljs (:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go]])) [cljs.core.async.macros :refer [go]]))
(def full-sync-request {:type :full-sync-request})
(defn full-sync [state]
{:type :full-sync
:full-state state})
(def empty-state state/empty-state) (def empty-state state/empty-state)
(defn update-states [states diff] (defn update-states [states diff]
(let [state (state/get-current-state states) (let [state (state/get-latest states)
new-state (diff/patch-state state diff)] new-state (diff/patch-state state diff)]
(state/add-history-state states new-state))) (state/add states new-state)))
(defn diff-msg [shadow state] (defn diff-msg [shadow state]
(let [diff (diff/diff-states shadow state) (let [diff (diff/diff-states shadow state)]
hash (hasch/uuid shadow)] {:type :diff
(message/diff-msg diff hash))) :diff diff}))
(defmulti handle-event (fn [event] (:type event))) (defmulti handle-event (fn [event] (:type event)))
(defmethod handle-event :diff [{:keys [hash diff states shadow client?]}] (defmethod handle-event :diff [{:keys [diff states shadow client?]}]
(let [history-shadow (state/get-history-state @states hash)] (let [history-shadow (state/get-tagged @states (:shadow-tag diff))]
(if history-shadow (if history-shadow
(let [new-states (swap! states update-states diff) (let [new-states (swap! states update-states diff)
new-state (state/get-current-state new-states) new-state (state/get-latest new-states)
new-shadow (diff/patch-state history-shadow diff)] new-shadow (diff/patch-state history-shadow diff true)]
{:out-event (when-not (state/empty-diff? diff) {:out-event (when-not (state/state= history-shadow new-state)
(diff-msg new-shadow new-state)) (diff-msg new-shadow new-state))
:new-states new-states :new-states new-states
:new-shadow new-shadow}) :new-shadow new-shadow})
(if client? (if client?
{:out-event message/full-sync-request {:out-event full-sync-request
:new-shadow shadow} :new-shadow shadow}
(let [state (state/get-current-state @states)] (let [state (state/get-latest @states)]
{:out-event (message/full-sync state) {:out-event (full-sync state)
:new-shadow state}))))) :new-shadow state})))))
(defmethod handle-event :full-sync-request [{:keys [states]}] (defmethod handle-event :full-sync-request [{:keys [states]}]
(let [state (state/get-current-state @states)] (let [state (state/get-latest @states)]
{:new-shadow state {:new-shadow state
:out-event (message/full-sync state)})) :out-event (full-sync state)}))
(defmethod handle-event :full-sync [{:keys [full-state states]}] (defmethod handle-event :full-sync [{:keys [full-state states]}]
(reset! states (state/new-state full-state)) (reset! states (state/new-states full-state))
{:new-shadow full-state}) {:new-shadow full-state})
(defmethod handle-event :default [msg] (defmethod handle-event :default [msg]
@ -61,8 +65,8 @@
(let [[v c] (a/alts! [new-states events] :priority true)] (let [[v c] (a/alts! [new-states events] :priority true)]
(cond (nil? v) nil ;; drop out of loop (cond (nil? v) nil ;; drop out of loop
(= c new-states) (= c new-states)
(do (when-not (= shadow v) (do (when-not (state/state= shadow v)
(swap! states state/add-history-state v) (swap! states state/add v)
(>! >remote (diff-msg shadow v))) (>! >remote (diff-msg shadow v)))
(recur shadow)) (recur shadow))
(= c events) (= c events)
@ -90,4 +94,4 @@
(>! new-states* v) (>! new-states* v)
(recur)))) (recur))))
(make-client-agent >remote events new-states* states) (make-client-agent >remote events new-states* states)
(a/put! >remote message/full-sync-request))) (a/put! >remote full-sync-request)))

9
src/cljx/grub/tag.cljx Normal file
View file

@ -0,0 +1,9 @@
(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))

View file

@ -2,18 +2,8 @@
(:require [grub.sync :as sync] (:require [grub.sync :as sync]
[clojure.test :refer :all] [clojure.test :refer :all]
[midje.sweet :refer :all] [midje.sweet :refer :all]
[hasch.core :as hasch]
[clojure.core.async :as a :refer [<!! >!! chan go]])) [clojure.core.async :as a :refer [<!! >!! chan go]]))
(defn hashed-states [& states]
(->> states
(map (fn [s] {:hash (hasch/uuid s)
:state s}))
(into [])))
(defn states-atom [& states]
(atom (apply hashed-states states)))
(defn <!!? [c] (defn <!!? [c]
(let [[v p] (a/alts!! [c (a/timeout 100)])] (let [[v p] (a/alts!! [c (a/timeout 100)])]
v)) v))

View file

@ -1,73 +1,76 @@
(ns grub.test.unit.diff (ns grub.test.unit.diff
(:require [grub.diff :as diff] (:require [grub.diff :as diff]
[clojure.test :refer :all])) [midje.sweet :refer :all]))
(def empty-diff {:grubs {:deleted #{} :updated nil} (def empty-diff {:grubs {:- #{} :+ nil}
:recipes {:deleted #{} :updated nil}}) :recipes {:- #{} :+ nil}})
(deftest diff-empty-states (fact "Diff of empty states is empty diff"
(let [empty-state {:grubs {} :recipes {}}] (let [empty-state {:grubs {} :recipes {}}]
(is (= empty-diff (diff/diff-states empty-state empty-state) => empty-diff))
(diff/diff-states empty-state empty-state)))))
(deftest diff-equal-states (fact "Diff of equal states is empty diff"
(is (= empty-diff (diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}} {:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})))) => empty-diff)
(deftest diff-added-grub (fact "Diff of one added grub has one updated grub"
(is (= {:grubs {:deleted #{} (diff/diff-states {:grubs {} :recipes {}}
:updated {"id" {:completed false, :text "asdf"}}} {:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
:recipes {:deleted #{} :updated nil}} => {:grubs {:- #{}
(diff/diff-states {:grubs {} :recipes {}} :+ {"id" {:completed false, :text "asdf"}}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})))) :recipes {:- #{} :+ nil}})
(deftest diff-deleted-grub (fact "Diff of one removed grub has one deleted grub"
(is (= {:grubs {:deleted #{"id"} (diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
:updated nil} {:grubs {} :recipes {}})
:recipes {:deleted #{} :updated nil}} =>
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}} {:grubs {:- #{"id"}
{:grubs {} :recipes {}})))) :+ nil}
:recipes {:- #{} :+ nil}})
(deftest diff-edited-grub (fact "Diff of one changed grub has updated grub"
(is (= {:grubs {:deleted #{} (diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
:updated {"id" {:text "asdf2"}}} {:grubs {"id" {:text "asdf2" :completed false}} :recipes {}})
:recipes {:deleted #{} :updated nil}} =>
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}} {:grubs {:- #{}
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}})))) :+ {"id" {:text "asdf2"}}}
:recipes {:- #{} :+ nil}})
(deftest diff-completed-grub (fact "Diff of one completed grub has updated grub"
(is (= {:grubs {:deleted #{} (diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
:updated {"id" {:completed true}}} {:grubs {"id" {:text "asdf" :completed true}} :recipes {}})
:recipes {:deleted #{} :updated nil}} => {:grubs {:- #{}
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}} :+ {"id" {:completed true}}}
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}})))) :recipes {:- #{} :+ nil}})
(deftest diff-added-recipe (fact "Diff of one added recipe has updated recipe"
(is (= {:grubs {:deleted #{} (diff/diff-states {:grubs {} :recipes {}}
:updated nil} {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:recipes {:deleted #{} :updated {"id" {:name "Blue Cheese Soup" :grubs "Some grubs"}}})
:grubs "Some grubs"}}}} =>
(diff/diff-states {:grubs {} :recipes {}} {:grubs {:- #{}
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup" :+ nil}
:grubs "Some grubs"}}})))) :recipes {:- #{} :+ {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}})
(deftest diff-edited-recipe (fact "Diff of one changed recipe has one updated recipe"
(is (= {:grubs {:deleted #{} (diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:updated nil} :grubs "Some grubs"}}}
:recipes {:deleted #{} :updated {"id" {:name "Bleu Cheese Soup" }}}} {:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup" :grubs "Some grubs"}}})
:grubs "Some grubs"}}} => {:grubs {:- #{}
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup" :+ nil}
:grubs "Some grubs"}}})))) :recipes {:- #{} :+ {"id" {:name "Bleu Cheese Soup" }}}})
(deftest diff-deleted-recipe (fact "Diff of one removed recipe has one deleted recipe"
(is (= {:grubs {:deleted #{} :updated nil} (diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:recipes {:deleted #{"id"} :updated nil}} :grubs "Some grubs"}}}
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup" {:grubs {} :recipes {}})
:grubs "Some grubs"}}} =>
{:grubs {} :recipes {}})))) {:grubs {:- #{} :+ nil}
:recipes {:- #{"id"} :+ nil}})
(def before-state (def before-state
{:grubs {:grubs
@ -107,8 +110,8 @@
(def expected-diff (def expected-diff
{:recipes {:recipes
{:deleted #{"recipe-deleted"} {:- #{"recipe-deleted"}
:updated :+
{"recipe-added" {"recipe-added"
{:name "Burgers" {:name "Burgers"
:grubs :grubs
@ -117,17 +120,47 @@
{:grubs {:grubs
"300 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n400 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n2 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"}}} "300 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n400 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n2 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"}}}
:grubs :grubs
{:deleted #{"grub-deleted"} {:- #{"grub-deleted"}
:updated :+
{"grub-completed" {:completed true} {"grub-completed" {:completed true}
"grub-updated" {:text "Ketchup"} "grub-updated" {:text "Ketchup"}
"grub-added" "grub-added"
{:completed false :text "Toothpaste"}}}}) {:completed false :text "Toothpaste"}}}})
(deftest diff-many-changes (fact "Diff of many changes has all changes"
(is (= expected-diff (diff/diff-states before-state after-state)))) (diff/diff-states before-state after-state) => expected-diff)
(deftest patch-returns-original-state (fact "Diff and patch of many changes returns original state with new tag"
(is (let [diff (diff/diff-states before-state after-state)
(let [diff (diff/diff-states before-state after-state)] result (diff/patch-state before-state diff)]
(= after-state (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})

View file

@ -1,35 +1,36 @@
(ns grub.test.unit.state (ns grub.test.unit.state
(:require [grub.state :as s] (:require [grub.state :as s]
[midje.sweet :refer :all] [midje.sweet :refer :all]))
[hasch.core :as hasch]))
(fact "Get current state returns last state" (fact "Get current state returns last state"
(let [states [{:hash "asdf" :state {:a :b}} (let [states [{:tag "1" :a :b}
{:hash "fdsa" :state {:c :d}}]] {:tag "2" :c :d}]]
(s/get-current-state states) => {:c :d})) (s/get-latest states) => {:tag "2" :c :d}))
(fact "Get history state returns state with given hash" (fact "Get history state returns state with given hash"
(let [states [{:hash "hash1" :state {:a :b}} (let [states [{:tag "1" :a :b}
{:hash "hash2" :state {:c :d}} {:tag "2" :c :d}
{:hash "hash3" :state {:e :f}}]] {:tag "3" :e :f}]]
(s/get-history-state states "hash1") => {:a :b} (s/get-tagged states "1") => {:tag "1" :a :b}
(s/get-history-state states "hash2") => {:c :d} (s/get-tagged states "2") => {:tag "2" :c :d}
(s/get-history-state states "hash3") => {:e :f})) (s/get-tagged states "3") => {:tag "3" :e :f}))
(fact "Add history state appends state to the end" (fact "Add history state appends state to the end"
(let [states [{:hash "hash1" :state {:a :b}} (let [states [{:tag "1" :a :b}
{:hash "hash2" :state {:c :d}}]] {:tag "2" :c :d}]]
(:state (last (s/add-history-state states {:e :f}))) => {:e :f})) (-> (s/add states {:e :f})
(last)
(dissoc :tag))
=> {:e :f}))
(fact "Add history state appends state to the end and drops first state if full" (fact "Add history state appends state to the end and drops first state if full"
(let [states (into [] (for [i (range 20)] {:hash (str "hash" i) :state {:i i}})) (let [states (into [] (for [i (range 20)] {:tag (str i) :i i}))
new-states (s/add-history-state states {:i 21})] new-states (s/add states {:i 21})]
(count new-states) => 20 (count new-states) => 20
(:state (last new-states)) => {:i 21} (dissoc (last new-states) :tag) => {:i 21}
(:state (first new-states)) => {:i 1})) (first new-states) => {:tag "1" :i 1}))
(fact "Add history state does not add consecutive duplicate states" (fact "Add history state does not add consecutive duplicate states"
(let [hash (hasch/uuid {:c :d}) (let [states [{:tag "1" :a :b}
states [{:hash "hash1" :state {:a :b}} {:tag "2" :c :d}]]
{:hash hash :state {:c :d}}]] (s/add states {:tag "2" :c :d}) => states))
(s/add-history-state states {:c :d}) => states))

View file

@ -1,112 +1,185 @@
(ns grub.test.unit.sync (ns grub.test.unit.sync
(:require [grub.state :as state] (:require [grub.state :as state]
[grub.sync :as sync] [grub.sync :as sync]
[midje.sweet :refer :all] [midje.sweet :refer :all]))
[hasch.core :as hasch]))
(defn hashed-states [& states] (facts "Server diff"
(->> states (fact "Server applies diff, returns empty diff with client tag, new server tag when no server changes"
(map (fn [s] {:hash (hasch/uuid s) (let [{:keys [new-states out-event new-shadow]}
:state s})) (sync/handle-event
(into []))) {:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}}
: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 => (just (just {:tag 0
:grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}})
(just {:tag #(not (nil? %))
: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}})})
new-shadow => {:tag 4
:grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}}))
(fact "Server applies diff and returns empty diff when no server changes" (fact "Server applies diff and returns changes when server has changed"
(let [states (atom (hashed-states (let [{:keys [new-states new-shadow out-event]}
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}})) (sync/handle-event
event {:type :diff {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}} :diff {:shadow-tag 0 :tag 4
:hash (:hash (first @states)) :grubs {:+ {"1" {:completed true}} :- #{}}}
:states states :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:shadow (:state (last @states)) {:tag 1 :grubs {"1" {:text "2 apples" :completed false}
:client? false} "2" {:text "3 onions" :completed false}}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)] :recipes {}}])
new-states => (hashed-states :shadow state/empty-state
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}} :client? false})]
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}) new-states =>
new-shadow {:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}} (just {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
out-event => {:type :diff {:tag 1 :grubs {"1" {:text "2 apples" :completed false}
:diff {:grubs {:deleted #{}, :updated nil}
:recipes {:deleted #{}, :updated nil}}
:hash (:hash (last new-states))}))
(fact "Client applies diff and returns empty diff when no client changes"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))
:states states
:shadow (:state (last @states))
:client? true}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}})
new-shadow => {:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
out-event => {:type :diff
:diff {:grubs {:deleted #{}, :updated nil}
:recipes {:deleted #{}, :updated nil}}
:hash (:hash (last new-states))}))
(fact "Server applies diff and returns changes when server has changed"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}} "2" {:text "3 onions" :completed false}}
:recipes {}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed true} (just {:tag #(not (nil? %)) :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}})})
new-shadow => {:tag 4
:grubs {"1" {:text "2 apples" :completed true}}
:recipes {}}))
(fact "Server forces full sync if client is out of 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 #{}}}
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
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 "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 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 {}}])
: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 =>
(just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
(just {:tag #(not (nil? %))
: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}})})))
(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 {}}])
(fact "Client 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? true})
:out-event)
=> nil)
(fact "Client updates server 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? true})
:new-shadow)
=> {:tag 4 :grubs {"1" {:completed false, :text "2 apples"}} :recipes {}}))
(facts "Full sync"
(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-states result) => nil
(: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}} "2" {:text "3 onions" :completed false}}
:recipes {}}) :recipes {}}})))
out-event => {:type :diff
:diff {:grubs {:deleted #{}
:updated {"2" {:completed false, :text "3 onions"}}}
:recipes {:deleted #{}, :updated nil}}
:hash (hasch/uuid {:grubs {"1" {:text "2 apples" :completed true}}
:recipes {}})}))
(fact "Server forces full sync if client is out of sync"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}}
:hash (:hash {:grubs {"0" {:text "milk" :completed false}}
:recipes {}})
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync
:full-state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))
(fact "Server sends full sync if client requests it"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :full-sync-request
:states states}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync
:full-state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))