Use timestamp tags instead of hashes
- Hashing was slow and we weren't using the hash properties much anyway
This commit is contained in:
parent
19ca650078
commit
8fe22b9a52
13 changed files with 385 additions and 269 deletions
|
@ -18,7 +18,8 @@
|
|||
(defn start
|
||||
"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
|
||||
"Shuts down and destroys the current development system."
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
[clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]]
|
||||
[sablono "0.2.17"]
|
||||
[cljs-uuid "0.0.4"]
|
||||
[net.polyc0l0r/hasch "0.2.3"]
|
||||
[com.cognitect/transit-clj "0.8.259"]
|
||||
[com.cognitect/transit-cljs "0.8.188"]]
|
||||
:profiles {:uberjar {:aot :all}
|
||||
|
|
|
@ -92,11 +92,11 @@
|
|||
(let [{:keys [db conn]} (db/connect db-name)
|
||||
new-states (chan)
|
||||
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})]
|
||||
(add-watch states :db (fn [_ _ 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)
|
||||
(db/update-db! db new-state)))))
|
||||
(println "Started server on localhost:" port)
|
||||
|
@ -106,6 +106,7 @@
|
|||
:stop-server stop-server
|
||||
:states states)))
|
||||
|
||||
|
||||
(defn stop [{:keys [db-conn stop-server states] :as system}]
|
||||
(remove-watch states :db)
|
||||
(stop-server)
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
agent-states (sync/sync-client! >remote events new-states states)]
|
||||
(add-watch states :render (fn [_ _ 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
|
||||
:ws ws
|
||||
:channels {:new-states new-states
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
(ns grub.diff
|
||||
(:require [clojure.data :as data]
|
||||
[clojure.set :as set]))
|
||||
[clojure.set :as set]
|
||||
[grub.tag :as tag]))
|
||||
|
||||
(defn deleted [a b]
|
||||
(set/difference (into #{} (keys a)) (into #{} (keys b))))
|
||||
|
@ -9,22 +10,39 @@
|
|||
(second (data/diff a b)))
|
||||
|
||||
(defn diff-maps [a b]
|
||||
{:deleted (deleted a b)
|
||||
:updated (updated a b)})
|
||||
(when (and (map? a) (map? b))
|
||||
{:- (deleted a b)
|
||||
:+ (updated a b)}))
|
||||
|
||||
(defn diff-states [prev next]
|
||||
(defn diff-keys [prev next]
|
||||
(->> prev
|
||||
(keys)
|
||||
(map (fn [k] [k (diff-maps (k prev) (k next))]))
|
||||
(filter #(not (nil? (second %))))
|
||||
(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]
|
||||
(-> state
|
||||
(#(apply dissoc % (into [] (:deleted diff))))
|
||||
(#(merge-with merge % (:updated diff)))))
|
||||
(#(apply dissoc % (into [] (:- diff))))
|
||||
(#(merge-with merge % (:+ diff)))))
|
||||
|
||||
(defn patch-state [state diff]
|
||||
(->> state
|
||||
(keys)
|
||||
(map (fn [k] [k (patch-map (k state) (k diff))]))
|
||||
(into {})))
|
||||
(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)))))))
|
||||
|
|
|
@ -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})
|
|
@ -1,32 +1,31 @@
|
|||
(ns grub.state
|
||||
(:require [grub.diff :as diff]
|
||||
[grub.util :as util]
|
||||
[hasch.core :as hasch]))
|
||||
[grub.tag :as tag]))
|
||||
|
||||
(def num-history-states 20)
|
||||
|
||||
(def empty-state {:grubs {} :recipes {}})
|
||||
(def empty-states [{:grubs {} :recipes {}}])
|
||||
(def empty-state {:tag (tag/oldest-tag) :grubs {} :recipes {}})
|
||||
|
||||
(defn new-state [state]
|
||||
[{:hash (hasch/uuid state)
|
||||
:state state}])
|
||||
(defn new-states [state]
|
||||
[(assoc state :tag (tag/new-tag))])
|
||||
|
||||
(defn get-current-state [states]
|
||||
(:state (last states)))
|
||||
(defn get-latest [states]
|
||||
(last states))
|
||||
|
||||
(defn get-history-state [states hash]
|
||||
(:state (first (filter #(= (:hash %) hash) states))))
|
||||
(defn get-tagged [states tag]
|
||||
(->> states
|
||||
(filter #(= (:tag %) tag))
|
||||
(first)))
|
||||
|
||||
(defn add-history-state [states new-state]
|
||||
(let [last-hash (:hash (last states))
|
||||
new-hash (hasch/uuid new-state)]
|
||||
(if (= last-hash new-hash)
|
||||
(defn add [states new-state]
|
||||
(let [last-state (last states)]
|
||||
(if (= last-state new-state)
|
||||
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)
|
||||
(into [] (rest new-states))
|
||||
new-states)))))
|
||||
|
||||
(defn empty-diff? [diff]
|
||||
(= diff {:recipes {:deleted #{}, :updated nil}, :grubs {:deleted #{}, :updated nil}}))
|
||||
(defn state= [a b]
|
||||
(= (dissoc a :tag) (dissoc b :tag)))
|
||||
|
|
|
@ -1,51 +1,55 @@
|
|||
(ns grub.sync
|
||||
(:require [grub.diff :as diff]
|
||||
[grub.message :as message]
|
||||
[grub.state :as state]
|
||||
[hasch.core :as hasch]
|
||||
#+clj [clojure.core.async :as a :refer [<! >! chan go]]
|
||||
#+cljs [cljs.core.async :as a :refer [<! >! chan]])
|
||||
#+cljs (:require-macros [grub.macros :refer [log logs]]
|
||||
[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)
|
||||
|
||||
(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)]
|
||||
(state/add-history-state states new-state)))
|
||||
(state/add states new-state)))
|
||||
|
||||
(defn diff-msg [shadow state]
|
||||
(let [diff (diff/diff-states shadow state)
|
||||
hash (hasch/uuid shadow)]
|
||||
(message/diff-msg diff hash)))
|
||||
(let [diff (diff/diff-states shadow state)]
|
||||
{:type :diff
|
||||
:diff diff}))
|
||||
|
||||
(defmulti handle-event (fn [event] (:type event)))
|
||||
|
||||
(defmethod handle-event :diff [{:keys [hash diff states shadow client?]}]
|
||||
(let [history-shadow (state/get-history-state @states hash)]
|
||||
(defmethod handle-event :diff [{:keys [diff states shadow client?]}]
|
||||
(let [history-shadow (state/get-tagged @states (:shadow-tag diff))]
|
||||
(if history-shadow
|
||||
(let [new-states (swap! states update-states diff)
|
||||
new-state (state/get-current-state new-states)
|
||||
new-shadow (diff/patch-state history-shadow diff)]
|
||||
{:out-event (when-not (state/empty-diff? diff)
|
||||
new-state (state/get-latest new-states)
|
||||
new-shadow (diff/patch-state history-shadow diff true)]
|
||||
{:out-event (when-not (state/state= history-shadow new-state)
|
||||
(diff-msg new-shadow new-state))
|
||||
:new-states new-states
|
||||
:new-shadow new-shadow})
|
||||
(if client?
|
||||
{:out-event message/full-sync-request
|
||||
{:out-event full-sync-request
|
||||
:new-shadow shadow}
|
||||
(let [state (state/get-current-state @states)]
|
||||
{:out-event (message/full-sync state)
|
||||
(let [state (state/get-latest @states)]
|
||||
{:out-event (full-sync state)
|
||||
:new-shadow state})))))
|
||||
|
||||
(defmethod handle-event :full-sync-request [{:keys [states]}]
|
||||
(let [state (state/get-current-state @states)]
|
||||
(let [state (state/get-latest @states)]
|
||||
{:new-shadow state
|
||||
:out-event (message/full-sync state)}))
|
||||
:out-event (full-sync state)}))
|
||||
|
||||
(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})
|
||||
|
||||
(defmethod handle-event :default [msg]
|
||||
|
@ -61,8 +65,8 @@
|
|||
(let [[v c] (a/alts! [new-states events] :priority true)]
|
||||
(cond (nil? v) nil ;; drop out of loop
|
||||
(= c new-states)
|
||||
(do (when-not (= shadow v)
|
||||
(swap! states state/add-history-state v)
|
||||
(do (when-not (state/state= shadow v)
|
||||
(swap! states state/add v)
|
||||
(>! >remote (diff-msg shadow v)))
|
||||
(recur shadow))
|
||||
(= c events)
|
||||
|
@ -90,4 +94,4 @@
|
|||
(>! new-states* v)
|
||||
(recur))))
|
||||
(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
9
src/cljx/grub/tag.cljx
Normal 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))
|
|
@ -2,18 +2,8 @@
|
|||
(:require [grub.sync :as sync]
|
||||
[clojure.test :refer :all]
|
||||
[midje.sweet :refer :all]
|
||||
[hasch.core :as hasch]
|
||||
[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]
|
||||
(let [[v p] (a/alts!! [c (a/timeout 100)])]
|
||||
v))
|
||||
|
|
|
@ -1,73 +1,76 @@
|
|||
(ns grub.test.unit.diff
|
||||
(:require [grub.diff :as diff]
|
||||
[clojure.test :refer :all]))
|
||||
[midje.sweet :refer :all]))
|
||||
|
||||
|
||||
(def empty-diff {:grubs {:deleted #{} :updated nil}
|
||||
:recipes {:deleted #{} :updated nil}})
|
||||
(def empty-diff {:grubs {:- #{} :+ nil}
|
||||
:recipes {:- #{} :+ nil}})
|
||||
|
||||
(deftest diff-empty-states
|
||||
(fact "Diff of empty states is empty diff"
|
||||
(let [empty-state {:grubs {} :recipes {}}]
|
||||
(is (= empty-diff
|
||||
(diff/diff-states empty-state empty-state)))))
|
||||
(diff/diff-states empty-state empty-state) => empty-diff))
|
||||
|
||||
(deftest diff-equal-states
|
||||
(is (= empty-diff
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
|
||||
(fact "Diff of equal states is empty diff"
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
|
||||
=> empty-diff)
|
||||
|
||||
(deftest diff-added-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:completed false, :text "asdf"}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
|
||||
(fact "Diff of one added grub has one updated grub"
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
|
||||
=> {:grubs {:- #{}
|
||||
:+ {"id" {:completed false, :text "asdf"}}}
|
||||
:recipes {:- #{} :+ nil}})
|
||||
|
||||
(deftest diff-deleted-grub
|
||||
(is (= {:grubs {:deleted #{"id"}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {} :recipes {}}))))
|
||||
(fact "Diff of one removed grub has one deleted grub"
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {} :recipes {}})
|
||||
=>
|
||||
{:grubs {:- #{"id"}
|
||||
:+ nil}
|
||||
:recipes {:- #{} :+ nil}})
|
||||
|
||||
(deftest diff-edited-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:text "asdf2"}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}}))))
|
||||
(fact "Diff of one changed grub has updated grub"
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}})
|
||||
=>
|
||||
{:grubs {:- #{}
|
||||
:+ {"id" {:text "asdf2"}}}
|
||||
:recipes {:- #{} :+ nil}})
|
||||
|
||||
(deftest diff-completed-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:completed true}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}}))))
|
||||
(fact "Diff of one completed grub has updated grub"
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}})
|
||||
=> {:grubs {:- #{}
|
||||
:+ {"id" {:completed true}}}
|
||||
:recipes {:- #{} :+ nil}})
|
||||
|
||||
(deftest diff-added-recipe
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}}
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}))))
|
||||
(fact "Diff of one added recipe has updated recipe"
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}})
|
||||
=>
|
||||
{:grubs {:- #{}
|
||||
:+ nil}
|
||||
:recipes {:- #{} :+ {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}})
|
||||
|
||||
(deftest diff-edited-recipe
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated {"id" {:name "Bleu Cheese Soup" }}}}
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
|
||||
:grubs "Some grubs"}}}))))
|
||||
(fact "Diff of one changed recipe has one updated recipe"
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
|
||||
:grubs "Some grubs"}}})
|
||||
=> {:grubs {:- #{}
|
||||
:+ nil}
|
||||
:recipes {:- #{} :+ {"id" {:name "Bleu Cheese Soup" }}}})
|
||||
|
||||
(deftest diff-deleted-recipe
|
||||
(is (= {:grubs {:deleted #{} :updated nil}
|
||||
:recipes {:deleted #{"id"} :updated nil}}
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {}}))))
|
||||
(fact "Diff of one removed recipe has one deleted recipe"
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {}})
|
||||
=>
|
||||
{:grubs {:- #{} :+ nil}
|
||||
:recipes {:- #{"id"} :+ nil}})
|
||||
|
||||
(def before-state
|
||||
{:grubs
|
||||
|
@ -107,8 +110,8 @@
|
|||
|
||||
(def expected-diff
|
||||
{:recipes
|
||||
{:deleted #{"recipe-deleted"}
|
||||
:updated
|
||||
{:- #{"recipe-deleted"}
|
||||
:+
|
||||
{"recipe-added"
|
||||
{:name "Burgers"
|
||||
:grubs
|
||||
|
@ -117,17 +120,47 @@
|
|||
{: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)"}}}
|
||||
:grubs
|
||||
{:deleted #{"grub-deleted"}
|
||||
:updated
|
||||
{:- #{"grub-deleted"}
|
||||
:+
|
||||
{"grub-completed" {:completed true}
|
||||
"grub-updated" {:text "Ketchup"}
|
||||
"grub-added"
|
||||
{:completed false :text "Toothpaste"}}}})
|
||||
|
||||
(deftest diff-many-changes
|
||||
(is (= expected-diff (diff/diff-states before-state after-state))))
|
||||
(fact "Diff of many changes has all changes"
|
||||
(diff/diff-states before-state after-state) => expected-diff)
|
||||
|
||||
(deftest patch-returns-original-state
|
||||
(is
|
||||
(let [diff (diff/diff-states before-state after-state)]
|
||||
(= after-state (diff/patch-state before-state 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})
|
||||
|
|
|
@ -1,35 +1,36 @@
|
|||
(ns grub.test.unit.state
|
||||
(:require [grub.state :as s]
|
||||
[midje.sweet :refer :all]
|
||||
[hasch.core :as hasch]))
|
||||
[midje.sweet :refer :all]))
|
||||
|
||||
(fact "Get current state returns last state"
|
||||
(let [states [{:hash "asdf" :state {:a :b}}
|
||||
{:hash "fdsa" :state {:c :d}}]]
|
||||
(s/get-current-state states) => {: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 [{:hash "hash1" :state {:a :b}}
|
||||
{:hash "hash2" :state {:c :d}}
|
||||
{:hash "hash3" :state {:e :f}}]]
|
||||
(s/get-history-state states "hash1") => {:a :b}
|
||||
(s/get-history-state states "hash2") => {:c :d}
|
||||
(s/get-history-state states "hash3") => {: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 [{:hash "hash1" :state {:a :b}}
|
||||
{:hash "hash2" :state {:c :d}}]]
|
||||
(:state (last (s/add-history-state states {:e :f}))) => {:e :f}))
|
||||
(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 drops first state if full"
|
||||
(let [states (into [] (for [i (range 20)] {:hash (str "hash" i) :state {:i i}}))
|
||||
new-states (s/add-history-state states {:i 21})]
|
||||
(let [states (into [] (for [i (range 20)] {:tag (str i) :i i}))
|
||||
new-states (s/add states {:i 21})]
|
||||
(count new-states) => 20
|
||||
(:state (last new-states)) => {:i 21}
|
||||
(:state (first new-states)) => {:i 1}))
|
||||
(dissoc (last new-states) :tag) => {:i 21}
|
||||
(first new-states) => {:tag "1" :i 1}))
|
||||
|
||||
(fact "Add history state does not add consecutive duplicate states"
|
||||
(let [hash (hasch/uuid {:c :d})
|
||||
states [{:hash "hash1" :state {:a :b}}
|
||||
{:hash hash :state {:c :d}}]]
|
||||
(s/add-history-state states {:c :d}) => states))
|
||||
(let [states [{:tag "1" :a :b}
|
||||
{:tag "2" :c :d}]]
|
||||
(s/add states {:tag "2" :c :d}) => states))
|
||||
|
|
|
@ -1,112 +1,185 @@
|
|||
(ns grub.test.unit.sync
|
||||
(:require [grub.state :as state]
|
||||
[grub.sync :as sync]
|
||||
[midje.sweet :refer :all]
|
||||
[hasch.core :as hasch]))
|
||||
[midje.sweet :refer :all]))
|
||||
|
||||
(defn hashed-states [& states]
|
||||
(->> states
|
||||
(map (fn [s] {:hash (hasch/uuid s)
|
||||
:state s}))
|
||||
(into [])))
|
||||
(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]}
|
||||
(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 {}}])
|
||||
: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"
|
||||
(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? false}
|
||||
{: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 "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}
|
||||
(fact "Server applies diff and returns changes when server has changed"
|
||||
(let [{:keys [new-states 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 state/empty-state
|
||||
:client? false})]
|
||||
new-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 {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed true}
|
||||
:recipes {}}
|
||||
(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}}
|
||||
: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 {}}}))
|
||||
:recipes {}}})))
|
||||
|
|
Loading…
Reference in a new issue