set up pages in UI
CI / Test + coverage (push) Successful in 1m9s

This commit is contained in:
2026-09-21 06:20:30 -09:00
parent 47f6cbf2d1
commit 10f4ca654b
14 changed files with 382 additions and 69 deletions
+4 -2
View File
@@ -2,7 +2,9 @@
(:require [tbd.ui.tictactoe :as tictactoe]))
(def default-db
{:calc {:x "" :y ""
{;; The page being shown (see tbd.ui.pages); it follows the URL's fragment.
:page :games
:calc {:x "" :y ""
;; {:status :idle}
;; {:status :loading}
;; {:status :ok :x "2" :y "3" :sum 5}
@@ -19,7 +21,7 @@
:items []
:new-title ""
:error nil}
;; The "Add this MCP server to Claude Code" section: :copied? is true right after the
;; The "Add this MCP server to Claude Code" page: :copied? is true right after the
;; command was copied to the clipboard.
:mcp {:copied? false}
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
+46 -3
View File
@@ -3,6 +3,7 @@
[clojure.string :as str]
[re-frame.core :as rf]
[tbd.ui.db :as db]
[tbd.ui.pages :as pages]
[tbd.ui.tictactoe :as tictactoe]))
;; Effect: request `uri` (GET unless `method` is given), then dispatch
@@ -21,12 +22,54 @@
:body %})))))
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
;; Coeffect: the URL's fragment, which says which page to start on.
(rf/reg-cofx
::hash
(fn [cofx _]
(assoc cofx :hash js/location.hash)))
;; Effect: show the page the URL names whenever it changes (a link was followed, or Back and
;; Forward were pressed). Only one listener is ever installed, however often this runs.
(defonce ^:private hash-listener (atom nil))
(rf/reg-fx
::listen-for-hash
(fn [_]
(when-let [old @hash-listener]
(.removeEventListener js/window "hashchange" old))
(reset! hash-listener #(rf/dispatch [::hash-changed js/location.hash]))
(.addEventListener js/window "hashchange" @hash-listener)))
;; Effect: put `hash` in the URL, which is how a page gets its own address (and a Back button).
(rf/reg-fx
::set-hash
(fn [hash]
(set! js/location.hash hash)))
(rf/reg-event-fx
::initialize
(fn [_ _]
{:db db/default-db
[(rf/inject-cofx ::hash)]
(fn [{:keys [hash]} _]
{:db (assoc db/default-db :page (pages/hash->page hash))
::listen-for-hash true
:dispatch [::load-user]}))
;; --- Pages ---
;; The URL changed (a link was followed, or Back was pressed): show the page it names.
(rf/reg-event-db
::hash-changed
(fn [db [_ hash]]
(assoc db :page (pages/hash->page hash))))
;; Go to a page without a link, as the browser tools do. The page changes right away; the URL
;; follows, and its change event finds the page already shown.
(rf/reg-event-fx
::navigate
(fn [{:keys [db]} [_ page]]
{:db (assoc db :page page)
::set-hash (pages/page->hash page)}))
(rf/reg-event-db
::set-operand
(fn [db [_ k value]]
@@ -169,7 +212,7 @@
{:db (update-in db [:todos :items] (fn [items] (filterv #(not= id (:id %)) items)))}
{:dispatch [::todo-failed resp]})))
;; --- MCP setup section ---
;; --- MCP setup page ---
;; Effect: put `text` on the clipboard. The Clipboard API only exists on secure origins (https
;; or localhost), so the view doesn't offer the button without it; failures are ignored.
+27
View File
@@ -0,0 +1,27 @@
(ns tbd.ui.pages
"The pages of the app, and how they map to the URL. The server only serves the shell at `/`,
so a page lives in the fragment (`/#/todos`): following a link between pages never asks the
server for anything, and a page can still be bookmarked or reloaded.")
(def pages
"The pages in the order of the tabs: {:id :title :login?}. :login? pages are only offered
(and only show anything) to someone who is logged in."
[{:id :games :title "Games"}
{:id :todos :title "Todos" :login? true}
{:id :ai-setup :title "AI setup"}])
(def default-page (:id (first pages)))
(def ^:private ids (into #{} (map :id) pages))
(defn page->hash
"The fragment that opens `page`: \"#/todos\"."
[page]
(str "#/" (name page)))
(defn hash->page
"The page a fragment (with or without its leading #) opens; the default page when it is empty
or names no page."
[hash]
(let [id (keyword (second (re-find #"^#?/?([^/?]*)" (or hash ""))))]
(if (contains? ids id) id default-page)))
+1
View File
@@ -2,6 +2,7 @@
(:require [re-frame.core :as rf]
[tbd.ui.tictactoe :as tictactoe]))
(rf/reg-sub ::page (fn [db _] (:page db)))
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
(rf/reg-sub ::user (fn [db _] (:user db)))
+51 -28
View File
@@ -2,6 +2,7 @@
(:require [clojure.string :as str]
[re-frame.core :as rf]
[tbd.ui.events :as events]
[tbd.ui.pages :as pages]
[tbd.ui.subs :as subs]
[tbd.ui.webmcp :as webmcp]))
@@ -23,15 +24,17 @@
"Sends GET /api/add?x=..&y=.. on submit and shows the sum the server returns."
[]
(let [result @(rf/subscribe [::subs/result])]
[:form {:on-submit (fn [e]
(.preventDefault e)
(rf/dispatch [::events/add]))}
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
[operand-input :x "x"]
"+"
[operand-input :y "y"]
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
[result-line result]]))
[:section
[:h2 "Calculator"]
[:form {:on-submit (fn [e]
(.preventDefault e)
(rf/dispatch [::events/add]))}
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
[operand-input :x "x"]
"+"
[operand-input :y "y"]
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
[result-line result]]]))
(defn- todo-item [{:keys [id title done]}]
[:li {:style {:display "flex" :gap "0.5rem" :align-items "center" :margin "0.25rem 0"}}
@@ -52,7 +55,7 @@
^{:key (:id todo)} [todo-item todo])]))
(defn todos
"Your todos (GET/POST /api/todos, PATCH/DELETE /api/todos/:id). Only shown when logged in."
"Your todos (GET/POST /api/todos, PATCH/DELETE /api/todos/:id)."
[]
(let [{:keys [new-title error] :as state} @(rf/subscribe [::subs/todos])]
[:section
@@ -105,20 +108,13 @@
[]
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
(defn- collapsible
"A section the reader can fold away by its heading (a native <details>, closed to start with)."
[title & body]
(into [:details {:style {:margin-top "2rem"}}
[:summary {:style {:cursor "pointer"}}
[:h2 {:style {:display "inline"}} title]]]
body))
(defn mcp-setup
"How to add this server to Claude Code: a command to copy, then signing in once."
[]
(let [command (mcp-command)
copied? @(rf/subscribe [::subs/mcp-copied?])]
[collapsible "Add this MCP server to Claude Code"
[:section
[:h2 "Add this MCP server to Claude Code"]
[:p "Run this in your terminal:"]
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
@@ -139,7 +135,8 @@
"The page's own tools for a browser agent (WebMCP, see tbd.ui.webmcp): how to connect an MCP
client to them. The token is pasted into the WebMCP widget in the corner of the page."
[]
[collapsible "Browser tools (WebMCP)"
[:section
[:h2 "Browser tools (WebMCP)"]
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator, the todos and the tic-tac-toe game) to "
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
", and you see them work on the page. To connect:"]
@@ -168,16 +165,42 @@
"Log out"]
nil))
(defn- login-prompt
"What the todos page says to someone who isn't logged in (nothing while we don't know yet)."
[{:keys [status message]}]
(case status
:anonymous [:section
[:h2 "Todos"]
[:p [:a {:href "/auth/login"} "Log in"] " to see your todos."]]
:error [:p {:role "alert" :style {:color "crimson"}} message]
nil))
(defn- current-page [page user]
(case page
:games [:<> [tic-tac-toe] [calculator]]
:todos (if (= :logged-in (:status user)) [todos] [login-prompt user])
:ai-setup [:<> [mcp-setup] [browser-tools]]))
(defn- tabs
"One link per page, the current one marked. The todos are only offered when logged in."
[current user]
[:nav {:aria-label "Pages" :style {:display "flex" :flex-wrap "wrap" :gap "1rem"
:border-bottom "1px solid GrayText" :padding-bottom "0.5rem"}}
(for [{:keys [id title login?]} pages/pages
:when (or (not login?) (= :logged-in (:status user)))]
^{:key id}
[:a (cond-> {:href (pages/page->hash id)}
(= id current) (assoc :aria-current "page" :style {:font-weight "bold"}))
title])])
(defn app []
(let [user @(rf/subscribe [::subs/user])]
(let [page @(rf/subscribe [::subs/page])
user @(rf/subscribe [::subs/user])]
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
[:h1 "tbd"]
[calculator]
(when (= :logged-in (:status user))
[todos])
[tic-tac-toe]
[mcp-setup]
[browser-tools]
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
[tabs page user]
[current-page page user]
[:p {:style {:margin-top "2rem"}}
[:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
(when-let [link (auth-link user)]
[:<> " · " link])]]))
+12
View File
@@ -26,6 +26,12 @@
(defn- fail! [message] (throw (js/Error. message)))
(defn- show!
"Opens `page` (see tbd.ui.pages), the way a user would click its tab, so a tool that acts on
the page does it where it can be seen."
[page]
(rf/dispatch-sync [::events/navigate page]))
(defn- settled
"A promise of the app-db as soon as `(pred db)` holds (right away if it already does), or a
rejection after `timeout-ms`. Requests made by events finish some time after the event is
@@ -117,6 +123,7 @@
(fail! "invalid arguments: x and y must be numbers"))
(when (= :loading (get-in (db) [:calc :result :status]))
(fail! "an addition is already in progress"))
(show! :games)
(rf/dispatch-sync [::events/set-operand :x (str x)])
(rf/dispatch-sync [::events/set-operand :y (str y)])
(rf/dispatch-sync [::events/add])
@@ -142,6 +149,7 @@
(fail! "invalid arguments: title must be a non-empty string"))
(-> (logged-in-todos)
(.then (fn [_]
(show! :todos)
(rf/dispatch-sync [::events/set-new-title title])
;; The field is emptied when the todo was added; a failure leaves it as it is.
(change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")))
@@ -156,6 +164,7 @@
(-> (logged-in-todos)
(.then (fn [items]
(let [todo (find-todo! items id)]
(show! :todos)
(if (= done (boolean (:done todo)))
todo
(-> (change-todos [::events/set-done id done]
@@ -170,6 +179,7 @@
(-> (logged-in-todos)
(.then (fn [items]
(find-todo! items id)
(show! :todos)
(change-todos [::events/delete-todo id]
#(nil? (find-todo (get-in % [:todos :items]) id))
"the todo to go")))
@@ -208,12 +218,14 @@
(fail! (str "it is " (name turn) "'s turn, not " player "'s")))
(when (board cell)
(fail! (str "cell " cell " is taken")))
(show! :games)
(rf/dispatch-sync [::events/ttt-move cell])
(tic-tac-toe-state)))
(defn- new-tic-tac-toe-game
"Presses New game."
[_]
(show! :games)
(rf/dispatch-sync [::events/ttt-reset])
(tic-tac-toe-state))
+2 -1
View File
@@ -21,6 +21,7 @@
(use-fixtures :each
tu/fake-server-fixture
tu/url-fixture
{:before (fn []
(reset! @#'webmcp/widget nil)
;; The widget logs every tool it registers. (Not console.log as a whole: that is
@@ -57,7 +58,7 @@
(-> (ping #js {})
(.then (fn [result] (is (= "pong" (.. result -content (at 0) -text))))))))
(fn []
(tu/shows "it found out who is logged in, and shows the todos" "section h2" "Todos"))
(tu/eventually (fn [] (some #(= "Todos" (.-textContent %)) (tu/q-all "nav a")))))
(fn []
(is (= :logged-in (get-in @rf-db/app-db [:user :status])))
;; The hot-reload hook redraws and registers the tools again, without starting over.
+23
View File
@@ -18,6 +18,7 @@
(fn [value] (swap! effects conj [id value])))
(use-fixtures :each
tu/url-fixture
{:before (fn [] (reset! effects []) (reset! queued []))}
(tu/stub-effects-fixture {::events/fetch (record ::events/fetch)
::events/copy-to-clipboard (record ::events/copy-to-clipboard)
@@ -75,6 +76,28 @@
(testing "it asks who is logged in"
(is (= [{:uri "/auth/me" :on-response [::events/user-response]}] (requests)))))
;; --- Pages ---
(deftest initialize-opens-the-page-in-the-url-test
(set! js/location.hash "#/ai-setup")
(dispatch! [::events/initialize])
(is (= :ai-setup (:page (app-db)))))
(deftest hash-changed-test
(start!)
(reset! effects [])
(dispatch! [::events/hash-changed "#/todos"])
(is (= :todos (:page (app-db))))
(dispatch! [::events/hash-changed "#/nowhere"])
(is (= :games (:page (app-db))) "an unknown address shows the first page")
(is (= [] (requests)) "changing the page asks the server for nothing"))
(deftest navigate-test
(start!)
(dispatch! [::events/navigate :ai-setup])
(is (= :ai-setup (:page (app-db))) "the page changes at once")
(is (= "#/ai-setup" js/location.hash) "and gets its address"))
;; --- Calculator ---
(deftest set-operand-test
+26
View File
@@ -0,0 +1,26 @@
(ns tbd.ui.pages-test
(:require [cljs.test :refer [deftest is testing]]
[tbd.ui.pages :as pages]))
(deftest page->hash-test
(is (= "#/games" (pages/page->hash :games)))
(is (= "#/ai-setup" (pages/page->hash :ai-setup))))
(deftest hash->page-test
(testing "every page has an address that opens it"
(doseq [{:keys [id]} pages/pages]
(is (= id (pages/hash->page (pages/page->hash id))))))
(testing "with or without the #"
(is (= :todos (pages/hash->page "#/todos")))
(is (= :todos (pages/hash->page "/todos")))
(is (= :todos (pages/hash->page "todos"))))
(testing "what follows the page's name is ignored"
(is (= :todos (pages/hash->page "#/todos/1?a=b"))))
(testing "no page, or an unknown one, is the first page"
(is (= :games pages/default-page))
(doseq [hash [nil "" "#" "#/" "#/nowhere" "#/Todos"]]
(is (= :games (pages/hash->page hash)) (pr-str hash)))))
(deftest pages-test
(is (= [:games :todos :ai-setup] (mapv :id pages/pages)) "the order of the tabs")
(is (= [:todos] (mapv :id (filter :login? pages/pages)))))
+3
View File
@@ -23,16 +23,19 @@
(deftest plain-subs-test
(testing "a new page"
(is (= "" (sub [::subs/operand :x])))
(is (= :games (sub [::subs/page])))
(is (= {:status :idle} (sub [::subs/result])))
(is (= {:status :unknown} (sub [::subs/user])))
(is (= (:todos db/default-db) (sub [::subs/todos])))
(is (false? (sub [::subs/mcp-copied?]))))
(testing "they follow the db"
(db! assoc :page :todos)
(db! assoc-in [:calc :x] "4")
(db! assoc-in [:calc :result] {:status :ok :x "4" :y "1" :sum 5})
(db! assoc :user {:status :logged-in :login "alice"})
(db! assoc-in [:todos :items] [{:id 1 :title "a"}])
(db! assoc-in [:mcp :copied?] true)
(is (= :todos (sub [::subs/page])))
(is (= "4" (sub [::subs/operand :x])))
(is (= 5 (:sum (sub [::subs/result]))))
(is (= "alice" (:login (sub [::subs/user]))))
+6
View File
@@ -76,6 +76,12 @@
:after (fn []
(aset js/globalThis "fetch" real-fetch))})
(def url-fixture
"Each test starts on the page's own address, with no fragment (the page shown is the first)."
(let [reset-hash! #(set! js/location.hash "")]
{:before reset-hash!
:after reset-hash!}))
(defn log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
;; --- Stubbing re-frame effects ---
+116 -28
View File
@@ -7,21 +7,29 @@
[re-frame.db :as rf-db]
[tbd.ui.db :as db]
[tbd.ui.events :as events]
[tbd.ui.pages :as pages]
[tbd.ui.test-util :as tu :refer [q q-all text shows steps click! type! submit!]]
[tbd.ui.views :as views]
[tbd.ui.webmcp :as webmcp]))
(use-fixtures :each
tu/fake-server-fixture
tu/url-fixture
tu/page-fixture
;; The Copy button's 2 seconds are up when the test says so (see mcp-copy-button-test).
(tu/stub-effects-fixture {:dispatch-later (fn [_])}))
(defn- open-page!
"Starts the app as core/init does, minus the WebMCP widget."
[]
(rf/dispatch-sync [::events/initialize])
(tu/mount! [views/app]))
"Starts the app as core/init does, minus the WebMCP widget, on the page whose address is
`page` (the first, when there is none)."
([] (open-page! nil))
([page]
(when page (set! js/location.hash (pages/page->hash page)))
(rf/dispatch-sync [::events/initialize])
(tu/mount! [views/app])))
(defn- tab-titles []
(mapv #(.-textContent %) (q-all "nav a")))
(defn- todos-section
"The todos, which are only on the page when someone is logged in."
@@ -39,7 +47,7 @@
[todos]
(tu/log-in!)
(swap! tu/server assoc :todos todos :next-id (inc (count todos)))
(steps open-page!
(steps #(open-page! :todos)
#(tu/eventually todos-section)
#(tu/eventually (fn [] (or (q "ul li") (= "Nothing to do." (text "section > p")))))))
@@ -59,8 +67,8 @@
(is (= ["API docs" "swagger.json" "Log in"] (nav-links)))
(is (= "/auth/login" (.getAttribute (q "a[href=\"/auth/login\"]") "href")))
(is (nil? (todos-section)) "no todos without a login")
(is (= ["Tic-tac-toe" "Add this MCP server to Claude Code" "Browser tools (WebMCP)"]
(mapv #(.-textContent %) (q-all "h2"))))))
(is (= ["Games" "AI setup"] (tab-titles)) "no Todos tab without a login")
(is (= ["Tic-tac-toe" "Calculator"] (mapv #(.-textContent %) (q-all "h2"))) "the first page is Games")))
done)))
(deftest login-state-error-test
@@ -206,6 +214,7 @@
(async done
(reset! rf-db/app-db (-> db/default-db
(assoc :user {:status :logged-in :login "alice"})
(assoc :page :todos)
(assoc-in [:todos :status] :loading)))
(tu/finish
(steps
@@ -308,7 +317,9 @@
(is (empty? (filter #(= "gold" (.. % -style -background)) (q-all "section button[aria-label^=Cell]"))))))
done)))
;; --- The setup sections ---
;; --- The AI setup page ---
(defn- open-ai-setup! [] (open-page! :ai-setup))
(defn- with-clipboard
"Runs `f` with navigator.clipboard set to `clipboard`, and takes it away afterwards."
@@ -321,16 +332,15 @@
(async done
(tu/finish
(steps
open-page!
#(tu/eventually (fn [] (q "details")))
open-ai-setup!
#(tu/eventually (fn [] (q "section pre")))
(fn []
(let [details (first (q-all "details"))]
(is (nil? (.getAttribute details "open")) "folded away to start with")
(is (= "Add this MCP server to Claude Code" (text "details summary")))
(is (= "claude mcp add --transport http tbd http://localhost:3000/mcp" (text "details pre code"))
(let [section (first (q-all "section"))]
(is (= "Add this MCP server to Claude Code" (text "section h2")))
(is (= "claude mcp add --transport http tbd http://localhost:3000/mcp" (text "section pre code"))
"the command names the address the page was loaded from")
(is (= ["claude" "/mcp" "tbd"]
(mapv #(.-textContent %) (.querySelectorAll details "ol code")))))))
(mapv #(.-textContent %) (.querySelectorAll section "ol code")))))))
done)))
(deftest mcp-no-copy-button-without-clipboard-test
@@ -338,10 +348,10 @@
(async done
(tu/finish
(steps
open-page!
#(tu/eventually (fn [] (q "details")))
open-ai-setup!
#(tu/eventually (fn [] (q "section pre")))
(fn []
(is (nil? (q "details pre + button")))))
(is (nil? (q "section pre + button")))))
done)))
(deftest mcp-copy-button-test
@@ -352,30 +362,108 @@
#js {:writeText (fn [s] (swap! copied conj s) (js/Promise.resolve))}
(fn []
(steps
open-page!
#(tu/eventually (fn [] (q "details pre + button")))
open-ai-setup!
#(tu/eventually (fn [] (q "section pre + button")))
(fn []
(is (= "Copy" (text "details pre + button")))
(click! (q "details pre + button")))
#(shows "the button says so" "details pre + button" "Copied")
(is (= "Copy" (text "section pre + button")))
(click! (q "section pre + button")))
#(shows "the button says so" "section pre + button" "Copied")
(fn []
(is (= ["claude mcp add --transport http tbd http://localhost:3000/mcp"] @copied))
;; The 2 seconds are up.
(rf/dispatch-sync [::events/mcp-copied-timeout]))
#(shows "the button is back" "details pre + button" "Copy"))))
#(shows "the button is back" "section pre + button" "Copy"))))
done))))
(deftest browser-tools-test
(async done
(tu/finish
(steps
open-page!
#(tu/eventually (fn [] (second (q-all "details"))))
open-ai-setup!
#(tu/eventually (fn [] (second (q-all "section"))))
(fn []
(let [section (second (q-all "details"))]
(is (= "Browser tools (WebMCP)" (.-textContent (.querySelector section "summary"))))
(let [section (second (q-all "section"))]
(is (= "Browser tools (WebMCP)" (.-textContent (.querySelector section "h2"))))
(is (= (str (count webmcp/tools)) (.-textContent (.querySelector section "p strong")))
"it counts the tools the page really offers")
(is (= "https://webmcp.dev" (.getAttribute (.querySelector section "p a") "href")))
(is (= 4 (.-length (.querySelectorAll section "ol li")))))))
done)))
;; --- The tabs ---
(defn- current-tab []
(some-> (q "nav a[aria-current=page]") .-textContent))
(deftest tabs-test
(async done
(tu/finish
(steps
open-page!
#(tu/eventually (fn [] (q "nav a")))
(fn []
(is (= "Games" (current-tab)) "it starts on the first tab")
(is (= "#/games" (.getAttribute (q "nav a") "href")))
(is (some? (q "[role=status]")) "the game is on the page")
(is (nil? (q "details")) "nothing is folded away")
(click! (second (q-all "nav a"))))
#(tu/eventually (fn [] (= "AI setup" (current-tab))))
(fn []
(is (= "#/ai-setup" js/location.hash) "a page has an address of its own")
(is (nil? (q "[role=status]")) "the game is gone")
(is (= ["Add this MCP server to Claude Code" "Browser tools (WebMCP)"]
(mapv #(.-textContent %) (q-all "h2"))))
;; Back.
(set! js/location.hash "#/games"))
#(tu/eventually (fn [] (= "Games" (current-tab))))
(fn [shown]
(is shown "following the address (Back, Forward, a typed one) shows that page")
(is (some? (q "[role=status]")))))
done)))
(deftest page-address-test
(async done
(tu/finish
(steps
#(open-page! :ai-setup)
#(tu/eventually (fn [] (q "nav a")))
(fn []
(is (= "AI setup" (current-tab)) "a page can be opened by its address")
(set! js/location.hash "#/nowhere"))
#(tu/eventually (fn [] (= "Games" (current-tab))))
(fn [shown]
(is shown "an address that names no page shows the first one")))
done)))
(deftest todos-tab-test
(async done
(tu/finish
(steps
#(log-in-page! [])
(fn []
(is (= ["Games" "Todos" "AI setup"] (tab-titles)))
(is (= "Todos" (current-tab)))
(click! (q "nav a"))
(tu/eventually (fn [] (= "Games" (current-tab)))))
(fn [_]
(is (nil? (todos-section)) "the todos are on their own page")
(click! (second (q-all "nav a"))))
#(tu/eventually (fn [] (todos-section)))
(fn [shown]
(is shown "and back")))
done)))
(deftest todos-page-without-login-test
;; Its address can be opened (a bookmark) without a login: the page says why it is empty.
(async done
(tu/finish
(steps
#(open-page! :todos)
#(tu/eventually (fn [] (some #{"Log in"} (nav-links))))
(fn []
(is (= ["Games" "AI setup"] (tab-titles)))
(is (nil? (todos-section)))
(is (= "Todos" (text "section h2")))
(is (= "Log in" (text "section p a")))
(is (= "/auth/login" (.getAttribute (q "section p a") "href")))))
done)))
+54
View File
@@ -20,6 +20,7 @@
:execute execute}))})
(use-fixtures :each
tu/url-fixture
{:before (fn []
(reset! registered {})
((:before tu/fake-server-fixture)))
@@ -147,6 +148,59 @@
(is (empty? (:todos @tu/server)) "nothing was sent")
(done))))))
;; A tool that acts on the page opens the page it acts on, as a click on its tab would.
(defn- page [] (:page @rf-db/app-db))
(deftest tools-open-the-page-they-act-on
(tu/log-in!)
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
:next-id 2)
(open-page!)
(async done
(-> (page-loaded)
(.then (fn [_]
(rf/dispatch-sync [::events/navigate :ai-setup])
(call "get_tic_tac_toe" {})))
(.then (fn [_]
(is (= :ai-setup (page)) "reading changes nothing")
(call "move_tic_tac_toe" {:cell 4})))
(.then (fn [_]
(is (= :games (page)))
(rf/dispatch-sync [::events/navigate :ai-setup])
(call "new_tic_tac_toe_game" {})))
(.then (fn [_]
(is (= :games (page)))
(rf/dispatch-sync [::events/navigate :ai-setup])
(call "add" {:x 1 :y 2})))
(.then (fn [_]
(is (= :games (page)) "the calculator is on the games page")
(call "list_todos" {})))
(.then (fn [_]
(is (= :games (page)))
(call "create_todo" {:title "buy milk"})))
(.then (fn [_]
(is (= :todos (page)))
(rf/dispatch-sync [::events/navigate :games])
(call "set_todo_done" {:id 1 :done true})))
(.then (fn [_]
(is (= :todos (page)))
(rf/dispatch-sync [::events/navigate :games])
(call "delete_todo" {:id 1})))
(.then (fn [_]
(is (= :todos (page)))
(done))))))
(deftest a-tool-that-fails-leaves-the-page-alone
(open-page!)
(async done
(-> (page-loaded)
(.then (fn [_] (call "create_todo" {:title "buy milk"})))
(.then (fn [r]
(is (:error? r) "not logged in")
(is (= :games (page)) "so there is no todos page to go to")
(done))))))
(deftest todos
(tu/log-in!)
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]