Skip to content

Instantly share code, notes, and snippets.

@lagenorhynque
Last active November 11, 2022 07:15
Show Gist options
  • Select an option

  • Save lagenorhynque/4a5c6557b62ba09ba513cc93b40c95e3 to your computer and use it in GitHub Desktop.

Select an option

Save lagenorhynque/4a5c6557b62ba09ba513cc93b40c95e3 to your computer and use it in GitHub Desktop.
re-frame à la spec

re-frame à la spec


Self-introduction

lagénorhynque /laʒenɔʁɛ̃k/ カマイルカ

(defprofile lagénorhynque
  :name         "Kent OHASHI"

  :languages    [Clojure Haskell Python Scala
                 English français Deutsch русский]

  :interests    [programming language-learning mathematics]

  :contributing [github.com/japan-clojurians/clojure-site-ja])

twitter icon


  1. Example App

  2. Introduction to re-frame

  3. Integration with clojure.spec


Example App

rock-paper-scissors game


source code


demo

$ git clone git@github.com:lagenorhynque/rock-paper-scissors.git
$ cd rock-paper-scissors
$ lein figwheel dev

or

from Emacs: cider-jack-in-clojurescript


Game start


Select your hand


Result: lose 😇


Select your hand


Result: draw 🤔


Select your hand


Result: win 😆


Introduction to re-frame


A Reagent Framework For Writing SPAs, in Clojurescript.


features

  • based on Reagent

  • data-oriented design

  • purely functional for the most part


similar frameworks


React Reagent
JSX (React element) Hiccup-like DSL
React component ClojureScript function
props args of component function
state reagent.core/atom

cf. ClojureScript & ReagentでReact入門してみた


re-frame's data flow

(a.k.a. "dominoes")

  1. event dispatch

  2. event handling

  3. effect handling

  4. query

  5. view

  6. DOM


1. event dispatch

  • dispatch synchronously to [::events/initialize-db]
(defn ^:export init []
  (re-frame/dispatch-sync [::events/initialize-db])
  (dev-setup)
  (mount-root))

src/cljs/rock_paper_scissors/core.cljs#L19-L22


2. event handling

  • event handler for ::initialize-db
(re-frame/reg-event-db
  ::initialize-db
  (fn  [_ _]
    db/default-db))

src/cljs/rock_paper_scissors/events.cljs#L6-L9


3. effect handling

  • effect handler for :db
(reg-fx
  :db
  (fn [value]
    (if-not (identical? @app-db value)
      (reset! app-db value))))

src/re_frame/fx.cljc#L162-L166


4. query

  • subscription for ::scene
(re-frame/reg-sub
  ::scene
  (fn [db _]
    (:scene db)))

src/cljs/rock_paper_scissors/subs.cljs#L5-L8


5. view

  • subscribe to [::subs/scene]
(defn main-panel []
  (case @(re-frame/subscribe [::subs/scene])
    ::db/start [start-button "Game Start"]
    ::db/now-playing [hands]
    ::db/over [:div
               [:h1 @(re-frame/subscribe
                      [::subs/you-enemy-hands])]
               [result]
               [start-button "Next Game"]]))

src/cljs/rock_paper_scissors/views.cljs#L26-L33


6. DOM


1'. event dispatch

  • dispatch to [::events/next-game]
(defn start-button [label]
  [:input {:type "button"
           :on-click #(re-frame/dispatch [::events/next-game])
           :value label}])

src/cljs/rock_paper_scissors/views.cljs#L8-L11


2'. event handling

  • event handler for ::next-game
(re-frame/reg-event-db
  ::next-game
  (fn [db _]
    (assoc db :scene ::db/now-playing)))

src/cljs/rock_paper_scissors/events.cljs#L11-L14


3'. effect handling

  • effect handler for :db
(reg-fx
  :db
  (fn [value]
    (if-not (identical? @app-db value)
      (reset! app-db value))))

src/re_frame/fx.cljc#L162-L166


4'. query

  • subscription for ::scene
(re-frame/reg-sub
  ::scene
  (fn [db _]
    (:scene db)))

src/cljs/rock_paper_scissors/subs.cljs#L5-L8


5'. view

  • subscribe to [::subs/scene]
(defn main-panel []
  (case @(re-frame/subscribe [::subs/scene])
    ::db/start [start-button "Game Start"]
    ::db/now-playing [hands]
    ::db/over [:div
               [:h1 @(re-frame/subscribe
                      [::subs/you-enemy-hands])]
               [result]
               [start-button "Next Game"]]))

src/cljs/rock_paper_scissors/views.cljs#L26-L33


6'. DOM


1''. event dispatch

  • dispatch to [::events/select-your-hand h]
(defn hands []
  [:div (map (fn [h]
               ^{:key h}
               [:input {:type "button"
                        :on-click
                        #(re-frame/dispatch
                          [::events/select-your-hand h])
                        :value h}])
             [::rps/rock ::rps/paper ::rps/scissors])])

src/cljs/rock_paper_scissors/views.cljs#L13-L19


2''. event handling

  • event handler for ::events/select-your-hand
(re-frame/reg-event-fx
  ::select-your-hand
  [(re-frame/inject-cofx ::cofx/select-enemy-hand)]
  (fn [{:keys [db enemy-hand]} [_ h]]
    {:db (assoc db
                :you h
                :enemy enemy-hand
                :scene ::db/over)}))

src/cljs/rock_paper_scissors/events.cljs#L16-L23


3''. effect handling

  • coeffect handler for ::select-enemy-hand
(re-frame/reg-cofx
  ::select-enemy-hand
  (fn [cofx _]
    (assoc cofx
           :enemy-hand (rps/->hand (rand-int 3)))))

src/cljs/rock_paper_scissors/cofx.cljs#L5-L9


  • effect handler for :db
(reg-fx
  :db
  (fn [value]
    (if-not (identical? @app-db value)
      (reset! app-db value))))

src/re_frame/fx.cljc#L162-L166


4''. query

  • subscription for ::scene
(re-frame/reg-sub
  ::scene
  (fn [db _]
    (:scene db)))

src/cljs/rock_paper_scissors/subs.cljs#L5-L8


  • subscription for ::you-enemy
(re-frame/reg-sub
  ::you-enemy
  (fn [db _]
    (select-keys db [:you :enemy])))

src/cljs/rock_paper_scissors/subs.cljs#L10-L13

  • subscription for ::you-enemy-hands
(re-frame/reg-sub
  ::you-enemy-hands
  :<- [::you-enemy]
  (fn [{:keys [you enemy]} _]
    (str (name you) "(YOU) VS " (name enemy) "(ENEMY)")))

src/cljs/rock_paper_scissors/subs.cljs#L15-L19


  • subscription for ::fight-result
(re-frame/reg-sub
  ::fight-result
  :<- [::you-enemy]
  (fn [{:keys [you enemy]} _]
    (rps/fight you enemy)))

src/cljs/rock_paper_scissors/subs.cljs#L21-L25


  • subscription for ::result-color
(re-frame/reg-sub
  ::result-color
  :<- [::fight-result]
  (fn [r _]
    (case r
      ::rps/win "red"
      ::rps/lose "blue"
      ::rps/draw "gray")))

src/cljs/rock_paper_scissors/subs.cljs#L27-L34


5''. view

  • subscribe to [::subs/scene]
  • subscribe to [::subs/you-enemy-hands]
(defn main-panel []
  (case @(re-frame/subscribe [::subs/scene])
    ::db/start [start-button "Game Start"]
    ::db/now-playing [hands]
    ::db/over [:div
               [:h1 @(re-frame/subscribe
                      [::subs/you-enemy-hands])]
               [result]
               [start-button "Next Game"]]))

src/cljs/rock_paper_scissors/views.cljs#L26-L33


  • subscribe to [::subs/fight-result]
  • subscribe to [::subs/result-color]
(defn result []
  (let [r @(re-frame/subscribe [::subs/fight-result])]
    [:h1 {:style {:color @(re-frame/subscribe
                           [::subs/result-color])}}
     r]))

src/cljs/rock_paper_scissors/views.cljs#L21-L24


6''. DOM


Integration with clojure.spec


speccing policy

  • split directories/namespaces for specs

  • use clojure.spec in development only

  • spec domain logic

  • spec db data


split directories/namespaces for specs

directory structure

rock-paper-scissors
├── specs
│   └── cljs
│       └── rock_paper_scissors
│           └── *
│               └── specs.cljs
├── src
│   └── cljs
│       └── rock_paper_scissors
│           └── *.cljs
└── test
    └── cljs
        └── rock_paper_scissors
            ├── *_test.cljs
            └── runner.cljs

use clojure.spec in development only

cljsbuild settings

  :cljsbuild
  {:builds
   [{:id           "dev"
     :source-paths ["src/cljs" "specs/cljs"]
     ,,,}
    {:id           "min"
     :source-paths ["src/cljs"]
     ,,,}
    {:id           "test"
     :source-paths ["src/cljs" "specs/cljs" "test/cljs"]
     ,,,}
    ]}

project.clj#L40-L70


spec domain logic

specs for rock-paper-scissors data

(s/def ::hand #{::rps/rock ::rps/paper ::rps/scissors})

(s/def ::hand-num (s/int-in 0 3))

(s/def ::result #{::rps/win ::rps/lose ::rps/draw})

specs/cljs/rock_paper_scissors/rps/specs.cljs#L5-L9


specs for rock-paper-scissors functions

(s/fdef rps/<-hand
  :args (s/cat :hand ::hand)
  :ret ::hand-num)

(s/fdef rps/->hand
  :args (s/cat :num ::hand-num)
  :ret ::hand)

(s/fdef rps/fight
  :args (s/cat :you ::hand
               :enemy ::hand)
  :ret ::result)

specs/cljs/rock_paper_scissors/rps/specs.cljs#L11-L22


spec db data

(s/def ::you ::rps.specs/hand)

(s/def ::enemy ::rps.specs/hand)

(s/def ::scene #{::db/start ::db/now-playing ::db/over})

(s/def ::db (s/keys :req-un [::you ::enemy ::scene]))

specs/cljs/rock_paper_scissors/db/specs.cljs#L6-L12


testing policy

  • test domain logic

  • test events via dispatch

  • test subscriptions via subscribe

    • do not test views

test domain logic

example-based tests with specs instrumented

(t/use-fixtures
  :once {:before #(stest/instrument)})

(t/deftest test-fight
  (t/testing "rock-paper-scissors"
    (t/is (= ::sut/win (sut/fight ::sut/rock ::sut/scissors)))
    (t/is (= ::sut/lose (sut/fight ::sut/scissors ::sut/rock)))
    (t/is (= ::sut/draw (sut/fight ::sut/paper ::sut/paper)))))

test/cljs/rock_paper_scissors/rps_test.cljs#L10-L17


property-based tests using specs

(tc/defspec prop-test-fight
  1000
  (let [fspec (s/get-spec #'sut/fight)]
    (prop/for-all [[you enemy] (-> fspec :args s/gen)]
      (s/valid? (:ret fspec)
                (sut/fight you enemy)))))

test/cljs/rock_paper_scissors/rps_test.cljs#L33-L38


test events and subscriptions

(t/use-fixtures  ; instrument specs
  :once {:before #(stest/instrument)})

(defn test-fixtures []
  (re-frame/reg-fx  ; mock :db effect
    :db
    (fn [value]  ; validate db with specs
      (when-not (s/valid? ::db.specs/db value)
        (throw (ex-info "db spec check failed"
                        (s/explain-data ::db.specs/db value))))
      (if-not (identical? @app-db value)
        (reset! app-db value)))))

test/cljs/rock_paper_scissors/events_test.cljs#L16-L26


(t/deftest test-initialize-db
  (re-frame.test/run-test-sync
   (test-fixtures)
   (re-frame/dispatch [::sut/initialize-db])
   (t/is ::db/start @(re-frame/subscribe [::subs/scene]))
   (t/is {:you ::rps/rock
          :enemy ::rps/rock}
         @(re-frame/subscribe [::subs/you-enemy]))))

test/cljs/rock_paper_scissors/events_test.cljs#L28-L35


(t/deftest test-select-your-hand
  (re-frame.test/run-test-sync
   (test-fixtures)
   (re-frame/reg-cofx  ; mock ::cofx/select-enemy-hand coeffect
     ::cofx/select-enemy-hand
     (fn [cofx _]
       (assoc cofx :enemy-hand ::rps/rock)))
   (re-frame/dispatch [::sut/initialize-db])
   (t/testing "draw"
     (re-frame/dispatch [::sut/next-game])
     (re-frame/dispatch [::sut/select-your-hand ::rps/rock])
     (t/is ::db/over @(re-frame/subscribe [::subs/scene]))
     (t/is "rock(YOU) VS rock(ENEMY)" @(re-frame/subscribe [::subs/you-enemy-hands]))
     (t/is ::rps/draw @(re-frame/subscribe [::subs/fight-result]))
     (t/is "gray" @(re-frame/subscribe [::subs/result-color])))))

test/cljs/rock_paper_scissors/events_test.cljs#L44-L73


re-frame x clojure.spec

  • unobtrusive use of clojure.spec
    • only in development

    • in separate directory/file/namespace

    • focus on domain logic and db


A little effort to write specs and tests can make our ClojureScript frontend life much happier!

Lisp Alien


Further Reading

example code


Reagent


re-frame


clojure.spec

#!/usr/bin/env bash
# npm install -g reveal-md
reveal-md re-frame-a-la-spec.md --theme night --highlight-theme monokai-sublime $@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment