Merge branch 'differential_sync'

This commit is contained in:
Nicholas Kariniemi 2014-10-06 19:27:32 +03:00
commit 48e7ef1b50
22 changed files with 831 additions and 478 deletions

View file

@ -17,24 +17,33 @@
[clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]]
[om "0.7.0"]
[sablono "0.2.17"]
[cljs-uuid "0.0.4"]]
:profiles {:uberjar {:aot :all}}
[cljs-uuid "0.0.4"]
[net.polyc0l0r/hasch "0.2.3"]]
:profiles {:uberjar {:aot :all}
:dev {:dependencies [[midje "1.6.3"]]}}
:min-lein-version "2.1.2"
:plugins [[lein-cljsbuild "1.0.3"]
[lein-ring "0.8.6"]]
:cljsbuild {:builds {:dev {:source-paths ["src/cljs"]
[lein-ring "0.8.6"]
[com.keminglabs/cljx "0.4.0"]]
:cljsbuild {:builds {:dev {:source-paths ["src/cljs" "target/generated/cljs"]
:compiler {:output-dir "public/js/out"
:output-to "public/js/grub.js"
:optimizations :none
:source-map true}}
:prod {:source-paths ["src/cljs"]
:prod {:source-paths ["src/cljs" "target/generated/cljs"]
:compiler {:output-to "public/js/grub.min.js"
:optimizations :advanced
:pretty-print false
:preamble ["react/react.min.js"]
:externs ["react/externs/react.js"]}}}}
:source-paths ["src/clj" "src/test"]
:test-paths ["spec/clj"]
:cljx {:builds [{:source-paths ["src/cljx"]
:output-path "target/classes"
:rules :clj}
{:source-paths ["src/cljx"]
:output-path "target/generated/cljs"
:rules :cljs}]}
:source-paths ["src/clj" "src/test" "target/classes"]
:test-paths ["src/test"]
:ring {:handler grub.core/app}
:uberjar-name "grub-standalone.jar"
:main grub.core)

View file

@ -23,9 +23,9 @@ h3 {
}
.recipe-add-grubs-btn {
float: right;
clear: both;
position: absolute;
margin: 2px;
right: 16px;
}
.hidden {
@ -133,16 +133,25 @@ tr:hover .grub-close {
.recipe-header {
margin: 0px;
padding: 0px;
padding-right: 72px;
border-bottom: none;
}
.recipe-header.edit {
padding-right: 0px;
}
.recipe-header.new {
padding-right: 0px;
}
.recipe-header-input {
border: none;
box-shadow: none;
transition: none;
border-bottom: none;
display: inline;
width: inherit;
width: 100%;
}
.recipe-header-input[readonly] {

View file

@ -2,13 +2,14 @@
(:require [grub.websocket :as ws]
[grub.db :as db]
[grub.test.integration.core :as integration-test]
[ring.middleware.reload :as reload]
[grub.state :as state]
[ring.middleware.file :as file]
[ring.util.response :as resp]
[compojure.core :refer [defroutes GET POST]]
[compojure.handler :as handler]
[compojure.route :as route]
[org.httpkit.server :as httpkit]
[clojure.core.async :as a :refer [<! >! chan go]]
[hiccup
[page :refer [html5]]
[page :refer [include-js include-css]]]
@ -41,40 +42,45 @@
(def index-page (atom dev-index-page))
(defn websocket-handler [request]
(when (:websocket? request)
(httpkit/with-channel request ws-channel
(let [to-client (chan)
from-client (chan)]
(ws/add-connected-client! ws-channel to-client from-client)
(state/sync-new-client! to-client from-client)))))
(defroutes routes
(GET "/" [] ws/websocket-handler)
(GET "/" [] websocket-handler)
(GET "/" [] @index-page)
(GET "*/src/cljs/grub/:file" [file] (resp/file-response file {:root "src/cljs/grub"}))
(GET "/js/public/js/:file" [file] (resp/redirect (str "/js/" file)))
(route/files "/")
(route/not-found "<p>Page not found.</p>"))
(def app
(let [dev? true]
(if dev?
(reload/wrap-reload (handler/site #'routes) {:dirs ["src/clj"]})
(handler/site routes))))
(def default-port 3000)
(defn start-server [port]
(httpkit/run-server app {:port port}))
(httpkit/run-server (handler/site routes) {:port port}))
(defn run-integration-test []
(let [stop-server (start-server integration-test/server-port)]
(println "Starting integration test server on localhost:" integration-test/server-port)
(integration-test/run)
(stop-server)))
(defn start-production-server [{:keys [port mongo-url]}]
(reset! index-page prod-index-page)
(let [db-chan (db/connect-production-database mongo-url)]
(ws/pass-received-events-to-clients-and-db db-chan)
(let [to-db (chan)]
(db/connect-production-database to-db mongo-url)
(state/init-server to-db (db/get-current-state))
(println "Starting production server on localhost:" port)
(start-server port)))
(defn start-development-server [{:keys [port]}]
(let [db-chan (db/connect-development-database)]
(ws/pass-received-events-to-clients-and-db db-chan)
(let [to-db (chan)]
(db/connect-development-database to-db)
(state/init-server to-db (db/get-current-state))
(println "Starting development server on localhost:" port)
(start-server port)))

View file

@ -1,98 +1,29 @@
(ns grub.db
(:require [monger.core :as m]
(:require [grub.util :as util]
[grub.sync :as sync]
[monger.core :as m]
[monger.collection :as mc]
[monger.operators :as mo]
[clojure.core.async :as a :refer [<! >! chan go]]))
(def conn (atom nil))
(def db (atom nil))
(def grub-collection "grubs")
(def recipe-collection "recipes")
(defn clear-grubs []
(mc/drop @db grub-collection))
(defn clear-recipes []
(mc/drop @db recipe-collection))
(defn clear-all []
(clear-grubs)
(clear-recipes))
(defmulti handle-event :event :default :unknown-event)
(defn insert-grub [event]
(let [grub (-> event
(select-keys [:id :grub :completed])
(clojure.set/rename-keys {:id :_id}))]
(mc/insert @db grub-collection grub)))
(defmethod handle-event :add-grub [event]
(insert-grub event))
(defmethod handle-event :add-grub-list [event]
(doseq [grub-event (:grubs event)]
(insert-grub grub-event)))
(defmethod handle-event :complete-grub [event]
(mc/update @db grub-collection
{:_id (:id event)}
{mo/$set {:completed true}}))
(defmethod handle-event :uncomplete-grub [event]
(mc/update @db grub-collection
{:_id (:id event)}
{mo/$set {:completed false}}))
(defmethod handle-event :update-grub [event]
(let [orig (mc/find-one-as-map @db grub-collection {:_id (:id event)})
new (dissoc event :event-type :id)]
(mc/update-by-id @db grub-collection (:id event) (merge orig new))))
(defmethod handle-event :clear-all-grubs [event]
(clear-grubs))
(defmethod handle-event :remove-grub [event]
(mc/remove-by-id @db grub-collection (:id event)))
(defmethod handle-event :add-recipe [event]
(let [recipe (-> event
(select-keys [:id :name :grubs])
(clojure.set/rename-keys {:id :_id}))]
(mc/insert @db recipe-collection recipe)))
(defmethod handle-event :update-recipe [event]
(mc/update @db recipe-collection
{:_id (:id event)}
{mo/$set {:name (:name event) :grubs (:grubs event)}}))
(defmethod handle-event :remove-recipe [event]
(mc/remove-by-id @db recipe-collection (:id event)))
(defmethod handle-event :unknown-event [event]
(println "Cannot handle unknown event:" event))
(defn get-current-grubs []
(->> (mc/find-maps @db grub-collection)
(sort-by :_id)
(map #(select-keys % [:_id :grub :completed]))
(map #(clojure.set/rename-keys % {:_id :id}))
(vec)))
(defn get-current-recipes []
(->> (mc/find-maps @db recipe-collection)
(sort-by :_id)
(map #(select-keys % [:_id :name :grubs]))
(map #(clojure.set/rename-keys % {:_id :id}))
(vec)))
(def collection "grub-lists")
(def production-db "grub")
(def development-db "grub-dev")
(defn handle-incoming-events [in]
(a/go-loop [] (let [event (<! in)]
(handle-event event)
(recur))))
(defn clear-all []
(mc/drop @db collection))
(defn update-db! [state]
(mc/drop @db collection)
(mc/insert @db collection state))
(defn get-current-state []
(let [state (first (mc/find-maps @db collection))]
(if state
(dissoc state :_id)
sync/empty-state)))
(defn connect! [db-name mongo-url]
(if mongo-url
@ -101,16 +32,19 @@
(do (println "Connected to mongo at localhost:" db-name)
(m/connect))))
(defn connect-and-handle-events [db-name & [mongo-url]]
(let [in (chan)]
(handle-incoming-events in)
(let [_conn (connect! db-name mongo-url)]
(reset! conn _conn)
(reset! db (m/get-db _conn db-name)))
in))
(defn connect-and-handle-events [to-db db-name & [mongo-url]]
(a/go-loop []
(if-let [state (<! to-db)]
(do (println "DB got new state")
(update-db! state)
(recur))
(println "Database disconnected")))
(let [_conn (connect! db-name mongo-url)]
(reset! conn _conn)
(reset! db (m/get-db _conn db-name))))
(defn connect-production-database [mongo-url]
(connect-and-handle-events production-db mongo-url))
(defn connect-production-database [to-db mongo-url]
(connect-and-handle-events to-db production-db mongo-url))
(defn connect-development-database []
(connect-and-handle-events development-db))
(defn connect-development-database [to-db]
(connect-and-handle-events to-db development-db))

View file

@ -3,83 +3,16 @@
[org.httpkit.server :as httpkit]
[clojure.core.async :as a :refer [<! >! chan go]]))
(def incoming-events (chan))
(defn disconnected [status ws-channel to from]
(println "Client disconnected:" (.toString ws-channel) "with status" status)
(a/close! to)
(a/close! from))
(def connected-clients (atom {}))
(def ws-channel-id-count (atom 0))
(defn get-unique-ws-id []
(swap! ws-channel-id-count inc))
(defn add-connected-client! [ws-channel]
(let [ws-channel-id (get-unique-ws-id)
client-chan (chan)]
(swap! connected-clients #(assoc % ws-channel-id client-chan))
[ws-channel-id client-chan]))
(defn remove-connected-client! [status ws-channel ws-channel-id client-chan]
(println "Client disconnected:"
(.toString ws-channel)
(str "(" ws-channel-id ")")
"with status" status)
(swap! connected-clients #(dissoc % ws-channel-id))
(println (count @connected-clients) "client(s) still connected")
(a/close! client-chan))
(defn send-current-grubs-and-recipes-to-client [client-chan]
(let [add-grubs-event {:event :add-grub-list
:grubs (db/get-current-grubs)}
add-recipes-event {:event :add-recipe-list
:recipes (db/get-current-recipes)}]
(go (>! client-chan add-grubs-event)
(>! client-chan add-recipes-event))))
(defn on-receive [raw-event ws-channel-id client-chan]
(let [parsed-event (read-string raw-event)
event (assoc parsed-event :ws-channel ws-channel-id)]
(println "Received event" event)
(if (= (:event event) :send-all-items)
(send-current-grubs-and-recipes-to-client client-chan)
(go (>! incoming-events event)))))
(defn forward-other-events-to-client [c ws-channel]
(a/go-loop []
(when-let [event (<! c)]
(println "Send to client '" (str event) "'")
(httpkit/send! ws-channel (str event))
(recur))))
(defn set-up-new-connection [ws-channel]
(let [[ws-channel-id client-chan] (add-connected-client! ws-channel)]
(println "Client connected:" (.toString ws-channel) (str "(" ws-channel-id ")"))
(println (count @connected-clients) "client(s) connected")
(httpkit/on-close ws-channel #(remove-connected-client! % ws-channel ws-channel-id client-chan))
(httpkit/on-receive ws-channel #(on-receive % ws-channel-id client-chan))
(forward-other-events-to-client client-chan ws-channel)))
(defn websocket-handler [request]
(when (:websocket? request)
(httpkit/with-channel request channel (set-up-new-connection channel))))
(defn get-other-client-channels [my-ws-channel-id]
(-> @connected-clients
(dissoc my-ws-channel-id)
(vals)))
(defn push-event-to-others [orig-event]
(let [my-ws-channel-id (:ws-channel orig-event)
event (dissoc orig-event :ws-channel)]
(go (doseq [c (get-other-client-channels my-ws-channel-id)]
(>! c event)))))
(defn pass-received-events-to-clients-and-db [db-chan]
(let [in' (a/mult incoming-events)
to-others (chan)
to-database (chan)]
(a/tap in' to-others)
(a/tap in' to-database)
(a/go-loop [] (let [event (<! to-others)]
(push-event-to-others event)
(recur)))
(a/pipe to-database (a/map> #(dissoc % :ws-channel) db-chan))))
(defn add-connected-client! [ws-channel to from]
(println "Client connected:" (.toString ws-channel))
(a/go-loop [] (if-let [event (<! to)]
(do (httpkit/send! ws-channel (pr-str event))
(recur))
(httpkit/close ws-channel)))
(httpkit/on-receive ws-channel #(a/put! from (read-string %)))
(httpkit/on-close ws-channel #(disconnected % ws-channel to from)))

View file

@ -1,16 +1,16 @@
(ns grub.core
(:require [grub.state :as state]
[grub.websocket :as ws]
[grub.view.app :as view]
[cljs.core.async :as a :refer [<! >! chan]])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go]]))
(:require-macros [grub.macros :refer [log logs]]))
(defn wire-channels-together []
(let [to-remote (chan)
to-state (chan)
from-remote (ws/get-remote-chan to-remote)
from-state (state/update-state-and-render to-state)]
(a/pipe from-remote to-state)
(a/pipe from-state to-remote)))
(defn init-app []
(let [current-state (atom state/empty-state)
state-changes (view/render-app current-state)
to-remote (chan)
from-remote (chan)]
(ws/connect-client! to-remote from-remote)
(state/init-client from-remote to-remote state-changes current-state)))
(wire-channels-together)
(init-app)

View file

@ -1,78 +0,0 @@
(ns grub.state
(:require [grub.view.app :as view]
[cljs.core.async :as a :refer [<! >! chan]])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
(def app-state (atom {:grubs {}
:recipes {}}))
(defmulti handle-event (fn [event state] (:event event))
:default :unknown-event)
(defmethod handle-event :unknown-event [event state]
state)
(defn new-grub [id grub completed]
{:id id :grub grub :completed completed})
(defmethod handle-event :add-grub [event state]
(let [grub (new-grub (:id event) (:grub event) (:completed event))]
(assoc-in state [:grubs (:id grub)] grub)))
(defn map-by-key [key coll]
(->> coll
(map (fn [a] [(get a key) a]))
(into {})))
(defmethod handle-event :add-grub-list [event state]
(->> event
:grubs
(map-by-key :id)
(merge (:grubs state))
(assoc state :grubs)))
(defmethod handle-event :update-grub [event state]
(let [new-grub-info (dissoc event :event-type)
orig-grub (get-in state [:grubs (:id event)])]
(assoc-in state [:grubs (:id event)] (merge orig-grub new-grub-info))))
(defmethod handle-event :clear-all-grubs [event state]
(assoc state :grubs {}))
(defmethod handle-event :remove-grub [event state]
(assoc state :grubs (dissoc (:grubs state) (:id event))))
(defn new-recipe [id name grubs]
{:id id :name name :grubs grubs})
(defmethod handle-event :add-recipe [event state]
(let [recipe (new-recipe (:id event) (:name event) (:grubs event))]
(assoc-in state [:recipes (:id recipe)] recipe)))
(defmethod handle-event :add-recipe-list [event state]
(->> event
:recipes
(map-by-key :id)
(merge (:recipes state))
(assoc state :recipes)))
(defmethod handle-event :update-recipe [event state]
(-> state
(assoc-in [:recipes (:id event) :name] (:name event))
(assoc-in [:recipes (:id event) :grubs] (:grubs event))))
(defmethod handle-event :remove-recipe [event state]
(assoc state :recipes (dissoc (:recipes state) (:id event))))
(defn update-state-and-render [remote]
(let [out (chan)
view-events (view/render-app app-state)]
(go-loop []
(let [[event ch] (alts! [remote view-events])
new-state (handle-event event @app-state)]
(reset! app-state new-state)
(when (= ch view-events)
(>! out event))
(recur)))
out))

View file

@ -1,10 +1,10 @@
(ns grub.view.app
(:require [om.core :as om :include-macros true]
[sablono.core :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]]
[grub.view.dom :as dom]
(:require [grub.view.dom :as dom]
[grub.view.grub-list :as grub-list]
[grub.view.recipe-list :as recipe-list])
[grub.view.recipe-list :as recipe-list]
[om.core :as om :include-macros true]
[sablono.core :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
@ -26,29 +26,16 @@
(dom/on-window-scroll #(put! >events {:type :body-scroll :event %}))))))
(defn render-app [state]
(let [grub-add (chan)
grub-update (chan)
grub-clear-all (chan)
grub-remove (chan)
recipe-add (chan)
recipe-add-grubs (chan)
recipe-update (chan)
recipe-remove (chan)
out (a/merge [grub-add grub-update grub-clear-all grub-remove
recipe-add recipe-add-grubs recipe-update recipe-remove])
>events (chan)
<events (a/pub >events :type)]
(let [>events (chan)
<events (a/pub >events :type)
add-grubs-ch (chan)
state-changes (chan)]
(om/root app-view
state
{:target (.getElementById js/document "container")
:shared {:grub-add grub-add
:grub-update grub-update
:grub-clear-all grub-clear-all
:grub-remove grub-remove
:recipe-add recipe-add
:recipe-add-grubs recipe-add-grubs
:recipe-update recipe-update
:recipe-remove recipe-remove
:>events >events
:<events <events}})
out))
:shared {:>events >events
:<events <events
:add-grubs-ch add-grubs-ch}
:tx-listen (fn [{:keys [new-state]} _]
(put! state-changes new-state))})
state-changes))

View file

@ -1,38 +1,17 @@
(ns grub.view.grub
(:require [om.core :as om :include-macros true]
(:require [grub.view.dom :as dom]
[om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]]
[grub.view.dom :as dom]
[cljs-uuid.core :as uuid])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
(defn new-grub [grub]
(defn new-grub [text]
{:id (str "grub-" (uuid/make-random))
:grub grub
:text text
:completed false})
(defn add-event [grub]
(assoc (new-grub grub) :event :add-grub))
(defn add-list-event [grubs]
{:event :add-grub-list
:grubs grubs})
(defn edit-event [id grub]
{:event :update-grub
:id id
:grub grub})
(defn complete-event [{:keys [id completed]}]
{:event :update-grub
:id id
:completed (not completed)})
(defn remove-event [id]
{:event :remove-grub
:id id})
(def transitions
{:waiting {:mouse-down :pressed
:touch-start :pressed}
@ -54,21 +33,22 @@
timeout-id (js/setTimeout timeout-fn 500)]
(om/set-state! owner :timeout-id timeout-id))
[:pressed :waiting] (js/clearTimeout (om/get-state owner :timeout-id))
[:editing :waiting] (let [update-ch (om/get-shared owner :grub-update)
id (:id @(om/get-props owner))
edit-event (edit-event id (om/get-state owner :grub))]
(put! update-ch edit-event))
[:editing :waiting] (let [grub (om/get-props owner)]
(om/transact! grub #(assoc % :text (om/get-state owner :grub-text))))
nil)
(om/set-state! owner :edit-state next)))
(when-not (= current next) (om/set-state! owner :edit-state next))))
(defn view [{:keys [id grub completed] :as props} owner]
(defn view [{:keys [id text completed] :as grub} owner {:keys [remove-ch]}]
(reify
om/IInitState
(init-state [_]
(let [publisher (chan)]
{:edit-state :waiting
:grub grub
:unmounted false}))
{:edit-state :waiting
:grub-text text
:unmounted false})
om/IWillReceiveProps
(will-receive-props [this {:keys [text]}]
(om/set-state! owner :grub-text text))
om/IRenderState
(render-state [_ {:keys [edit-state] :as state}]
@ -77,9 +57,9 @@
{:class [(when completed "completed")
(when (= edit-state :pressed) "grub-active")
(when (= edit-state :editing) "edit")]
:on-click #(when (#{:waiting :pressed} edit-state)
(put! (om/get-shared owner :grub-update) (complete-event @props))
(.blur (om/get-node owner :grub-input)))
:on-click (fn [e] (when (#{:waiting :pressed} edit-state)
(om/transact! grub #(assoc % :completed (not completed)))
(.blur (om/get-node owner :grub-input))))
:on-mouse-down #(transition-state owner :mouse-down)
:on-mouse-up #(transition-state owner :mouse-up)
:on-mouse-leave #(transition-state owner :mouse-leave)
@ -90,12 +70,12 @@
{:type "text"
:readOnly (if (= edit-state :editing) "" "readonly")
:ref :grub-input
:value (:grub state)
:on-change #(om/set-state! owner :grub (.. % -target -value))
:value (:grub-text state)
:on-change #(om/set-state! owner :grub-text (.. % -target -value))
:on-key-up #(when (dom/enter-pressed? %) (transition-state owner :enter))}]
(when (= edit-state :editing)
[:span.glyphicon.glyphicon-remove.pull-right
{:on-click #(put! (om/get-shared owner :grub-remove) (remove-event id))}])]))
{:on-click #(put! remove-ch id)}])]))
om/IDidMount
(did-mount [_]

View file

@ -1,34 +1,37 @@
(ns grub.view.grub-list
(:require [om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]]
[grub.view.dom :as dom]
(:require [grub.view.dom :as dom]
[grub.view.grub :as grub-view]
[cljs-uuid.core :as uuid])
[om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! chan]])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
(defn get-grub-ingredient [grub]
(when-not (nil? (:grub grub))
(let [text (clojure.string/lower-case (:grub grub))
(when-not (nil? (:text grub))
(let [text (clojure.string/lower-case (:text grub))
match (re-find #"[a-z]{3}.*$" text)]
match)))
(defn sort-grubs [grubs]
(sort-by (juxt :completed get-grub-ingredient :grub) (vals grubs)))
(->> grubs
(vals)
(sort-by (juxt :completed get-grub-ingredient :text))))
(defn add-grub [add new-grub owner]
(when (not (empty? new-grub))
(om/set-state! owner :new-grub "")
(put! add (grub-view/add-event new-grub))))
(defn add-grub [owner grubs new-grub-text]
(when (not (empty? new-grub-text))
(let [new-grub (grub-view/new-grub new-grub-text)]
(om/set-state! owner :new-grub-text "")
(om/transact! grubs #(assoc % (:id new-grub) new-grub)))))
(defn view [props owner]
(defn view [grubs owner]
(reify
om/IInitState
(init-state [_]
{:new-grub ""})
{:new-grub-text ""
:remove-grub-ch (chan)})
om/IRenderState
(render-state [this {:keys [new-grub] :as state}]
(render-state [this {:keys [new-grub-text remove-grub-ch] :as state}]
(let [add (om/get-shared owner :grub-add)]
(html
[:div
@ -38,22 +41,35 @@
[:input.form-control#add-grub-input
{:type "text"
:placeholder "What do you need?"
:value new-grub
:value new-grub-text
:on-key-up #(when (dom/enter-pressed? %)
(add-grub add new-grub owner))
:on-change #(om/set-state! owner :new-grub (dom/event-val %))}]]
(add-grub owner grubs new-grub-text))
:on-change #(om/set-state! owner :new-grub-text (dom/event-val %))}]]
[:button.btn.btn-primary
{:id "add-grub-btn"
:type "button"
:on-click #(add-grub add new-grub owner)}
[:span.glyphicon.glyphicon-plus]]]
:on-click #(add-grub owner grubs new-grub-text)}
[:span.glyphicon.glyphicon-plus#add-grub-btn]]]
[:ul#grub-list.list-group
(for [grub (sort-grubs props)]
(om/build grub-view/view grub {:key :id}))]
(for [grub (sort-grubs grubs)]
(om/build grub-view/view grub {:key :id :opts {:remove-ch remove-grub-ch}}))]
[:button.btn.pull-right
{:id "clear-all-btn"
:class (when (empty? props) "hidden")
:class (when (empty? grubs) "hidden")
:type "button"
:on-click #(put! (om/get-shared owner :grub-clear-all)
{:event :clear-all-grubs})}
"Clear all"]])))))
:on-click #(om/update! grubs {})}
"Clear all"]])))
om/IWillMount
(will-mount [_]
(let [add-grubs-ch (om/get-shared owner :add-grubs-ch)
remove-grub-ch (om/get-state owner :remove-grub-ch)]
(go-loop []
(let [grubs-map (<! add-grubs-ch)]
(when-not (nil? grubs-map)
(om/transact! grubs #(merge % grubs-map))
(recur))))
(go-loop []
(let [id (<! remove-grub-ch)]
(when-not (nil? id)
(om/transact! grubs #(dissoc % id))
(recur))))))))

View file

@ -1,29 +1,19 @@
(ns grub.view.recipe
(:require [om.core :as om :include-macros true]
(:require [grub.view.dom :as dom]
[grub.view.grub :as grub-view]
[grub.util :as util]
[om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]]
[cljs-uuid.core :as uuid]
[grub.view.dom :as dom]
[grub.view.grub :as grub-view])
[cljs-uuid.core :as uuid])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
(defn add-event [name grubs]
{:event :add-recipe
:id (str "recipe-" (uuid/make-random))
(defn new-recipe [name grubs]
{:id (str "recipe-" (uuid/make-random))
:name name
:grubs grubs})
(defn update-event [id name grubs]
{:event :update-recipe
:id id
:name name
:grubs grubs})
(defn remove-event [id]
{:event :remove-recipe
:id id})
(defn parse-grubs-from-str [grubs-str]
(->> grubs-str
(clojure.string/split-lines)
@ -32,8 +22,8 @@
(defn add-grubs [add-grubs-ch grubs-str]
(let [grubs (parse-grubs-from-str grubs-str)
event (grub-view/add-list-event grubs)]
(put! add-grubs-ch event)))
grubs-map (util/map-by-key :id grubs)]
(put! add-grubs-ch grubs-map)))
(def transitions
{:waiting {:click :editing}
@ -44,38 +34,35 @@
(let [current (om/get-state owner :edit-state)
next (or (get-in transitions [current event]) current)]
(condp = [current next]
[:editing :waiting] (let [update-ch (om/get-shared owner :recipe-update)
id (:id @(om/get-props owner))
[:editing :waiting] (let [recipe (om/get-props owner)
name (om/get-state owner :name)
grubs (om/get-state owner :grubs)
event (update-event id name grubs)]
(put! update-ch event))
grubs (om/get-state owner :grubs)]
(om/transact! recipe #(assoc % :name name :grubs grubs)))
nil)
(om/set-state! owner :edit-state next)))
(when-not (= current next) (om/set-state! owner :edit-state next))))
(defn num-newlines [str]
(count (re-seq #"\n" str)))
(defn view [{:keys [id] :as props} owner]
(defn view [{:keys [id] :as recipe} owner {:keys [remove-recipe-ch]}]
(reify
om/IInitState
(init-state [_]
(let [publisher (chan)]
{:edit-state :waiting
:name (:name props)
:grubs (:grubs props)
:name (:name recipe)
:grubs (:grubs recipe)
:unmounted false}))
om/IWillReceiveProps
(will-receive-props [this next-props]
(om/set-state! owner :name (:name next-props))
(om/set-state! owner :grubs (:grubs next-props)))
(will-receive-props [this next-recipe]
(om/set-state! owner :name (:name next-recipe))
(om/set-state! owner :grubs (:grubs next-recipe)))
om/IRenderState
(render-state [this {:keys [edit-state name grubs]}]
(let [update (om/get-shared owner :recipe-update)
add-grubs-ch (om/get-shared owner :recipe-add-grubs)]
(let [update (om/get-shared owner :recipe-update)]
(html
[:div.panel.panel-default.recipe-panel
{:on-click
@ -83,16 +70,17 @@
(dom/click-on-elem? % (om/get-node owner :save-btn))))
(transition-state owner :click))}
[:div.panel-heading.recipe-header
{:class (when (= edit-state :editing) "edit")}
[:input.form-control.recipe-header-input
{:type "text"
:readOnly (if (= edit-state :editing) "" "readonly")
:value name
:on-change #(om/set-state! owner :name (dom/event-val %))}]
[:button.btn.btn-primary.btn-sm.recipe-add-grubs-btn
[:button.btn.btn-primary.btn-sm.recipe-add-grubs-btn
{:type "button"
:class (when (= edit-state :editing) "hidden")
:ref :add-grubs-btn
:on-click #(add-grubs add-grubs-ch grubs)}
:on-click #(add-grubs (om/get-shared owner :add-grubs-ch) grubs)}
[:span.glyphicon.glyphicon-plus]
" Grubs"]]
[:div.panel-body.recipe-grubs
@ -105,7 +93,7 @@
:on-change #(om/set-state! owner :grubs (dom/event-val %))}]
[:button.btn.btn-danger.pull-left.recipe-remove-btn
{:type "button"
:on-click #(put! (om/get-shared owner :recipe-remove) (remove-event id))}
:on-click #(put! remove-recipe-ch id)}
[:span.glyphicon.glyphicon-trash]]
[:button.btn.btn-primary.pull-right.recipe-done-btn
{:type "button"

View file

@ -1,20 +1,22 @@
(ns grub.view.recipe-list
(:require [om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! put! chan]]
[cljs-uuid.core :as uuid]
[grub.view.dom :as dom]
(:require [grub.view.dom :as dom]
[grub.view.grub :as grub-view]
[grub.view.recipe :as recipe])
[grub.view.recipe :as recipe]
[om.core :as om :include-macros true]
[sablono.core :as html :refer-macros [html]]
[cljs.core.async :as a :refer [<! chan]]
[cljs-uuid.core :as uuid])
(:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go go-loop]]))
(defn add-recipe [ch name grubs owner]
(defn add-recipe [owner name grubs]
(when (and (not (empty? name))
(not (empty? grubs)))
(om/set-state! owner :new-recipe-name "")
(om/set-state! owner :new-recipe-grubs "")
(put! ch (recipe/add-event name grubs))))
(let [recipes (om/get-props owner)
new-recipe (recipe/new-recipe name grubs)]
(om/set-state! owner :new-recipe-name "")
(om/set-state! owner :new-recipe-grubs "")
(om/transact! recipes #(assoc % (:id new-recipe) new-recipe)))))
(def transitions
{:waiting {:click :editing}
@ -28,7 +30,7 @@
[:editing :save :waiting] (let [add-ch (om/get-shared owner :recipe-add)
name (om/get-state owner :new-recipe-name)
grubs (om/get-state owner :new-recipe-grubs)]
(add-recipe add-ch name grubs owner))
(add-recipe owner name grubs))
nil)
(om/set-state! owner :edit-state next)))
@ -36,11 +38,10 @@
(reify
om/IInitState
(init-state [_]
(let [publisher (chan)]
{:edit-state :waiting
:new-recipe-name ""
:new-recipe-grubs ""
:unmounted false}))
{:edit-state :waiting
:new-recipe-name ""
:new-recipe-grubs ""
:unmounted false})
om/IRenderState
(render-state [this {:keys [edit-state new-recipe-name new-recipe-grubs]}]
@ -48,7 +49,7 @@
[:div.panel.panel-default.recipe-panel
{:on-click #(when (not (dom/click-on-elem? % (om/get-node owner :save-btn)))
(transition-state owner :click))}
[:div.panel-heading.recipe-header
[:div.panel-heading.recipe-header.new
[:input.form-control.recipe-header-input
{:id "new-recipe-name"
:type "text"
@ -67,7 +68,7 @@
{:type "button"
:ref :save-btn
:on-click #(transition-state owner :save)}
"Save"]]]))
[:span.glyphicon.glyphicon-ok#save-recipe-btn]]]]))
om/IWillMount
(will-mount [_]
@ -92,12 +93,24 @@
(defn view [recipes owner]
(reify
om/IRender
(render [this]
om/IInitState
(init-state [_]
{:remove-recipe-ch (chan)})
om/IRenderState
(render-state [_ {:keys [remove-recipe-ch]}]
(html
[:div
[:h3.recipes-title "Recipes"]
(om/build new-recipe-view recipes)
[:ul#recipe-list.list-group.recipe-list
(for [recipe (vals recipes)]
(om/build recipe/view recipe {:key :id}))]]))))
(om/build recipe/view
recipe
{:key :id :opts {:remove-recipe-ch remove-recipe-ch}}))]]))
om/IWillMount
(will-mount [_]
(let [remove-recipe-ch (om/get-state owner :remove-recipe-ch)]
(go-loop []
(let [removed-id (<! remove-recipe-ch)]
(when-not (nil? removed-id)
(om/transact! recipes #(dissoc % removed-id)))))))))

View file

@ -3,43 +3,43 @@
[cljs.reader]
goog.net.WebSocket
goog.events.EventHandler
goog.events.EventTarget)
goog.events.EventTarget
[hasch.core :as hasch])
(:require-macros [cljs.core.async.macros :refer [go go-loop]]
[grub.macros :refer [log logs]]))
(def websocket* (atom nil))
(def pending-events (atom []))
(def server-url (str "ws://" (.-host (.-location js/document))))
(def pending-msg (atom nil))
(defn on-connected [event]
(defn send-pending-msg [websocket]
(when (and (.isOpen websocket)
(not (nil? @pending-msg)))
(logs "Send message:" @pending-msg)
(.send websocket (pr-str @pending-msg))
(reset! pending-msg nil)))
(defn on-connected [websocket event]
(log "Connected:" event)
(when (> (count @pending-events))
(doseq [event @pending-events] (.send @websocket* event))
(reset! pending-events [])))
(send-pending-msg websocket))
(defn send-outgoing-events [ch]
(go-loop []
(let [event (<! ch)]
(if (.isOpen @websocket*)
(.send @websocket* event)
(swap! pending-events conj event))
(recur))))
(defn on-message [from event]
(let [msg (cljs.reader/read-string (.-message event))]
(a/put! from msg)))
(defn on-message-fn [out]
(fn [event]
(let [grub-event (cljs.reader/read-string (.-message event))]
(go (>! out grub-event)))))
(def ws (atom nil))
(defn get-remote-chan [to-remote]
(let [server-url (str "ws://" (.-host (.-location js/document)))
handler (goog.events.EventHandler.)
remote-events (chan)]
(reset! websocket* (goog.net.WebSocket.))
(.listen handler @websocket* goog.net.WebSocket.EventType.OPENED on-connected false)
(.listen handler @websocket* goog.net.WebSocket.EventType.MESSAGE (on-message-fn remote-events) false)
(.listen handler @websocket* goog.net.WebSocket.EventType.CLOSED #(log "Closed:" %) false)
(.listen handler @websocket* goog.net.WebSocket.EventType.ERROR #(log "Error:" %) false)
(send-outgoing-events to-remote)
(go (>! to-remote {:event :send-all-items}))
(.open @websocket* server-url)
remote-events))
(defn connect-client! [to from]
(let [handler (goog.events.EventHandler.)
websocket (goog.net.WebSocket.)
listen (fn [type fun] (.listen handler websocket type fun false))]
(reset! ws websocket)
(listen goog.net.WebSocket.EventType.OPENED (partial on-connected websocket))
(listen goog.net.WebSocket.EventType.MESSAGE (partial on-message from))
(listen goog.net.WebSocket.EventType.CLOSED #(log "Closed:" %))
(listen goog.net.WebSocket.EventType.ERROR #(log "Error:" %))
(go (loop []
(when-let [msg (<! to)]
(reset! pending-msg msg)
(send-pending-msg websocket)
(recur))))
(.open websocket server-url)))

30
src/cljx/grub/diff.cljx Normal file
View file

@ -0,0 +1,30 @@
(ns grub.diff
(:require [clojure.data :as data]
[clojure.set :as set]))
(defn deleted [a b]
(set/difference (into #{} (keys a)) (into #{} (keys b))))
(defn updated [a b]
(second (data/diff a b)))
(defn diff-maps [a b]
{:deleted (deleted a b)
:updated (updated a b)})
(defn diff-states [prev next]
(->> prev
(keys)
(map (fn [k] [k (diff-maps (k prev) (k next))]))
(into {})))
(defn patch-map [state diff]
(-> state
(#(apply dissoc % (into [] (:deleted diff))))
(#(merge-with merge % (:updated diff)))))
(defn patch-state [state diff]
(->> state
(keys)
(map (fn [k] [k (patch-map (k state) (k diff))]))
(into {})))

View file

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

98
src/cljx/grub/state.cljx Normal file
View file

@ -0,0 +1,98 @@
(ns grub.state
(:require [grub.diff :as diff]
[grub.message :as message]
[grub.sync :as sync]
#+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]]))
(defn make-agent
([client? in out states] (make-agent client? in out states sync/empty-state))
([client? in out states initial-shadow]
(go (loop [shadow initial-shadow]
(when-let [msg (<! in)]
(condp = (:type msg)
:diff
(let [states* @states
shadow (sync/get-history-state states* (:hash msg))]
(if shadow
(let [new-states (sync/apply-diff states* (:diff msg))
new-shadow (diff/patch-state shadow (:diff msg))
{:keys [diff hash]} (sync/diff-states (sync/get-current-state new-states) new-shadow)]
(when-not (= states* new-states)
(reset! states new-states))
(when-not (or client? (sync/empty-diff? (:diff msg)))
(>! out (message/diff-msg diff hash)))
(if client?
(recur new-shadow)
(recur (sync/get-current-state new-states))))
(if client?
(do (>! out message/full-sync-request)
(recur shadow))
(let [state (sync/get-current-state states*)]
(>! out (message/full-sync state))
(recur state)))))
:full-sync
(if client?
(let [state (:state msg)]
(reset! states (sync/new-state state))
(recur state))
(let [state (sync/get-current-state @states)]
(>! out (message/full-sync state)) ;; HERE
(recur state)))
:new-state
(let [{:keys [diff hash]} (sync/diff-states (:state msg) shadow)]
(when-not (sync/empty-diff? diff)
(>! out (message/diff-msg diff hash)))
(recur shadow))
(recur shadow)))))))
(defn make-server-agent
([in out states] (make-agent false in out states))
([in out states initial-shadow] (make-agent false in out states initial-shadow)))
(defn make-client-agent
([in out states] (make-agent true in out states))
([in out states initial-shadow] (make-agent true in out states initial-shadow)))
(def states (atom []))
(def empty-state sync/empty-state)
#+clj
(defn sync-new-client! [>client <client]
(let [client-id (java.util.UUID/randomUUID)
state-changes (chan)
state-change-events (a/map< (fn [s] {:type :new-state :state s}) state-changes)
client-events (chan)]
(add-watch states client-id (fn [_ _ _ new-states]
(a/put! state-changes (sync/get-current-state new-states))))
(a/go-loop []
(let [[val _] (a/alts! [<client state-change-events])]
(if val
(do (>! client-events val)
(recur))
(do (remove-watch states client-id)
(a/close! <client)
(a/close! state-change-events)))))
(make-server-agent client-events >client states)))
#+clj
(defn init-server [to-db initial-state]
(reset! states (sync/new-state initial-state))
(add-watch states :to-db (fn [_ _ old-states new-states]
(a/put! to-db (sync/get-current-state new-states)))))
#+cljs
(defn init-client [in out state-changes current-state]
(reset! states (sync/initial-state {} {}))
(add-watch states :render (fn [_ _ _ new-states]
(let [new-state (sync/get-current-state new-states)]
(reset! current-state new-state))))
(a/pipe (a/map< (fn [s]
(swap! states sync/add-history-state s)
{:type :new-state :state s}) state-changes) in)
(make-client-agent in out states)
(a/put! out message/full-sync-request))

41
src/cljx/grub/sync.cljx Normal file
View file

@ -0,0 +1,41 @@
(ns grub.sync
(:require [grub.diff :as diff]
[grub.util :as util]
[hasch.core :as hasch]))
(def empty-state {:grubs {} :recipes {}})
(defn initial-state [grubs recipes]
(let [state {:grubs (util/map-by-key :id grubs)
:recipes (util/map-by-key :id recipes)}]
[{:state state :hash (hasch/uuid state)}]))
(defn new-state [state]
[{:hash (hasch/uuid state)
:state state}])
(defn get-current-state [states]
(:state (last states)))
(defn get-history-state [states hash]
(:state (first (filter #(= (:hash %) hash) states))))
(defn add-history-state [states new-state]
(let [last-hash (:hash (last states))
new-hash (hasch/uuid new-state)]
(if (= last-hash new-hash)
states
(conj states {:hash new-hash :state new-state}))))
(defn diff-states [states shadow]
(let [state states;(get-current-state states)
]
{:hash (hasch/uuid shadow)
:diff (diff/diff-states shadow state)}))
(defn apply-diff [states diff]
(let [new-state (diff/patch-state (get-current-state states) diff)]
(add-history-state states new-state)))
(defn empty-diff? [diff]
(= diff {:recipes {:deleted #{}, :updated nil}, :grubs {:deleted #{}, :updated nil}}))

7
src/cljx/grub/util.cljx Normal file
View file

@ -0,0 +1,7 @@
(ns grub.util)
(defn map-by-key [key coll]
(->> coll
(map (fn [a] [(get a key) a]))
(into {})))

View file

@ -1,6 +1,8 @@
(ns grub.test.integration.core
(:require [grub.db :as db]
[grub.websocket :as ws]
[grub.state :as state]
[clojure.core.async :as a :refer [<! >! chan go]]
[clj-webdriver.taxi :as taxi]
[clj-webdriver.core :as webdriver]
[clojure.test :as test]))
@ -20,7 +22,7 @@
(defn add-grub [driver grub-text]
(taxi/input-text driver "#add-grub-input" grub-text)
(taxi/click driver {:text "Add"}))
(taxi/click driver "#add-grub-btn"))
(defn test-grubs-saved-to-server [url driver]
(taxi/to driver url)
@ -31,9 +33,9 @@
(taxi/refresh driver)
(Thread/sleep 200)
(doseq [grub grubs]
(test/is (taxi/find-element driver {:text grub})
(test/is (taxi/find-element driver {:value grub})
"Previously added grubs should be loaded on refresh")))
(db/clear-grubs))
(db/clear-all))
(defn test-added-grubs-sync [url driver1 driver2]
(taxi/to driver1 url)
@ -42,7 +44,7 @@
(doseq [grub grubs]
(add-grub driver1 grub))
(doseq [grub grubs]
(test/is (taxi/find-element driver2 {:text grub})
(test/is (taxi/find-element driver2 {:value grub})
"Added grubs should appear in other browser"))))
(defn get-rand-recipe []
@ -53,7 +55,7 @@
(taxi/click driver "#new-recipe-name")
(taxi/input-text driver "#new-recipe-name" name)
(taxi/input-text driver "#new-recipe-grubs" grubs)
(taxi/click driver {:text "Done"}))
(taxi/click driver "#save-recipe-btn"))
(defn test-added-recipes-sync [url driver1 driver2]
(taxi/to driver1 url)
@ -71,9 +73,9 @@
(test-added-recipes-sync site-url driver1 driver2))
(defn start-db-and-websocket-server! []
(let [db-chan (db/connect-and-handle-events "grub-integration-test")]
(db/clear-all)
(ws/pass-received-events-to-clients-and-db db-chan)))
(let [to-db (chan)]
(db/connect-and-handle-events to-db "grub-integration-test")
(state/init-server to-db (db/get-current-state))))
(defn run []
(println "Starting integration test")

View file

@ -0,0 +1,133 @@
(ns grub.test.unit.diff
(:require [grub.diff :as diff]
[clojure.test :refer :all]))
(def empty-diff {:grubs {:deleted #{} :updated nil}
:recipes {:deleted #{} :updated nil}})
(deftest diff-empty-states
(let [empty-state {:grubs {} :recipes {}}]
(is (= empty-diff
(diff/diff-states empty-state empty-state)))))
(deftest diff-equal-states
(is (= empty-diff
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
(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 {}}))))
(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 {}}))))
(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 {}}))))
(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 {}}))))
(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"}}}))))
(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"}}}))))
(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 {}}))))
(def before-state
{:grubs
{"grub-same" {:completed false
:text "3 garlic cloves"}
"grub-completed" {:completed false
:text "2 tomatoes"}
"grub-updated" {:completed false
:text "BBQ sauce"}
"grub-deleted" {:completed true
:text "diapers"}}
:recipes
{"recipe-same" {:grubs "3 T. butter\n1 yellow onion\n1 1/2 dl red pepper\n1 dl apple\n3 garlic cloves\n1 t. curry\n3 dl water\n2-2 1/2 T. wheat flour\n1 kasvisliemikuutio\n200 g blue cheese\n2 dl apple juice\n2 dl milk\n1 t. basil\n1 package take-and-bake french bread"
:name "Blue Cheese Soup"}
"recipe-updated" {:grubs "450 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n350 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n3 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"
:name "Beef Stew"}
"recipe-deleted" {:grubs "8 slices rye bread\n400 g chicken breast\nBBQ sauce\nketchup\nmustard\nbutter\n1 package rocket\n4 tomatoes\n2 red onions\n1 bottle Coca Cola"
:name "Chickenburgers"}}})
(def after-state
{:grubs
{"grub-same" {:completed false,
:text "3 garlic cloves"}
"grub-completed" {:completed true,
:text "2 tomatoes"}
"grub-updated" {:completed false,
:text "Ketchup"}
"grub-added" {:completed false
:text "Toothpaste"}}
:recipes
{"recipe-same" {:grubs "3 T. butter\n1 yellow onion\n1 1/2 dl red pepper\n1 dl apple\n3 garlic cloves\n1 t. curry\n3 dl water\n2-2 1/2 T. wheat flour\n1 kasvisliemikuutio\n200 g blue cheese\n2 dl apple juice\n2 dl milk\n1 t. basil\n1 package take-and-bake french bread"
:name "Blue Cheese Soup"}
"recipe-updated" {: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)"
:name "Beef Stew"}
"recipe-added" {:grubs "400 g ground beef\nhamburger buns\n2 red onions\n4 tomatoes\ncheddar cheese\nketchup\nmustard\npickles\nfresh basil\n1 bottle Coca Cola"
:name "Burgers"}}})
(def expected-diff
{:recipes
{:deleted #{"recipe-deleted"}
:updated
{"recipe-added"
{:name "Burgers"
:grubs
"400 g ground beef\nhamburger buns\n2 red onions\n4 tomatoes\ncheddar cheese\nketchup\nmustard\npickles\nfresh basil\n1 bottle Coca Cola"}
"recipe-updated"
{: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-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))))
(deftest patch-returns-original-state
(is
(let [diff (diff/diff-states before-state after-state)]
(= after-state (diff/patch-state before-state diff)))))

View file

@ -0,0 +1,206 @@
(ns grub.test.unit.state
(:require [grub.state :as state]
[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))
(fact "Applies diff an returns empty diff when no server changes"
(let [states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}})
msg {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))}
in (chan 1)
out (chan 1)]
(state/make-server-agent in out states)
(>!! in msg)
(let [response (<!!? out)]
@states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}})
response => {:type :diff
:diff {:grubs {:deleted #{}, :updated nil}
:recipes {:deleted #{}, :updated nil}}
:hash (:hash (last @states))})))
(fact "Applies diff and returns changes when server has changed"
(let [states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
msg {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))}
in (chan 1)
out (chan 1)]
(state/make-server-agent in out states)
(>!! in msg)
(let [response (<!!? out)]
@states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}
{:grubs {"1" {:text "2 apples" :completed true}
"2" {:text "3 onions" :completed false}}
:recipes {}})
response => {: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 "Force full sync if client is out of sync"
(let [states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
msg {:type :diff
:diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}}
:hash (:hash {:grubs {"0" {:text "milk" :completed false}}
:recipes {}})}
in (chan 1)
out (chan 1)]
(state/make-server-agent in out states)
(>!! in msg)
(let [response (<!!? out)]
@states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
response => {:type :full-sync
:state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}})))
(fact "Full sync if client requests it"
(let [states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
msg {:type :full-sync}
in (chan 1)
out (chan 1)]
(state/make-server-agent in out states)
(>!! in msg)
(let [response (<!!? out)]
@states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
response => {:type :full-sync
:state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}})))
(fact "Passes diffs of new states to client"
(let [states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}
"3" {:text "milk" :completed false}}
:recipes {}})
client-state {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
msg {:type :new-state
:state (:state (last @states))}
in (chan 1)
out (chan 1)]
(state/make-server-agent in out states client-state)
(>!! in msg)
(let [response (<!!? out)]
@states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}
"3" {:text "milk" :completed false}}
:recipes {}})
response => {:type :diff
:diff {:grubs {:deleted #{}
:updated {"2" {:text "3 onions" :completed false}
"3" {:text "milk" :completed false}}}
:recipes {:deleted #{}, :updated nil}}
:hash (hasch/uuid client-state)})))
(fact "Client-only changes synced with server"
(let [client-shadow {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}
client-states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed true}} :recipes {}})
server-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
server-states (states-atom server-shadow)
client-in (chan)
client-out (chan)
server-in (chan)
server-out (chan)
client-state-changes (chan 1)
msg {:type :new-state
:state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}]
(a/pipe client-out server-in)
(a/pipe server-out client-in)
(state/make-client-agent client-in client-out client-states server-shadow)
(state/make-server-agent server-in server-out server-states client-shadow)
(add-watch client-states :test (fn [_ _ _ new-states] (a/put! client-state-changes new-states)))
(>!! client-in msg)
(<!!? client-state-changes)
(:state (last @client-states)) => {:grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}}
(:state (last @server-states)) => {:grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}}))
(fact "Client and server changes synced"
(let [client-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
client-states (states-atom
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed true}} :recipes {}})
server-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
server-states (states-atom
server-shadow
{:grubs {"1" {:text "4 apples" :completed false}} :recipes {}})
client-in (chan)
client-out (chan)
server-in (chan)
server-out (chan)
msg {:type :new-state
:state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}
client-state-changes (chan 1)]
(a/pipe client-out server-in)
(a/pipe server-out client-in)
(state/make-client-agent client-in client-out client-states server-shadow)
(state/make-server-agent server-in server-out server-states client-shadow)
(add-watch client-states :test (fn [_ _ _ new-states] (a/put! client-state-changes new-states)))
(>!! client-in msg)
(<!!? client-state-changes)
@client-states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "4 apples"}}, :recipes {}})
@server-states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed false, :text "4 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "4 apples"}}, :recipes {}})))

View file

@ -0,0 +1,27 @@
(ns grub.test.unit.sync
(:require [grub.sync :as s]
[clojure.test :refer :all]
[hasch.core :as hasch]))
(deftest initial-state
(let [grubs [{:id "1" :text "2 bananas" :completed false}
{:id "2" :text "3 onions" :completed false}]
recipes []
expected-state {:grubs {"1" {:id "1" :text "2 bananas" :completed false}
"2" {:id "2" :text "3 onions" :completed false}}
:recipes {}}
expected-hash (hasch/uuid expected-state)]
(is (= [{:state expected-state :hash expected-hash}] (s/initial-state grubs recipes)))))
(deftest get-current-state-returns-last-state
(let [states [{:hash "asdf" :state {:a :b}}
{:hash "fdsa" :state {:c :d}}]]
(is (= {:c :d} (s/get-current-state states)))))
(deftest get-history-state-returns-state-with-hash
(let [states [{:hash "hash1" :state {:a :b}}
{:hash "hash2" :state {:c :d}}
{:hash "hash3" :state {:e :f}}]]
(is (= {:a :b} (s/get-history-state states "hash1")))
(is (= {:c :d} (s/get-history-state states "hash2")))
(is (= {:e :f} (s/get-history-state states "hash3")))))