update examples. fix bugs
This commit is contained in:
+16
-15
@@ -1,36 +1,37 @@
|
|||||||
(ns examples.counter
|
(ns examples.counter
|
||||||
"Simple counter example - demonstrates basic Elm architecture.
|
"Simple counter example - demonstrates basic Elm architecture.
|
||||||
Mirrors bubbletea's simple example."
|
Mirrors bubbletea's simple example."
|
||||||
(:require [tui.core :as tui]))
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]))
|
||||||
|
|
||||||
;; === Model ===
|
;; === Model ===
|
||||||
(def initial-model
|
(def initial-model
|
||||||
{:count 0})
|
{:count 0})
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [model msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
;; Quit on q or ctrl+c
|
;; Quit on q or ctrl+c
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
;; Increment on up/k
|
;; Increment on up/k
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :count inc) nil]
|
{:model (update model :count inc)}
|
||||||
|
|
||||||
;; Decrement on down/j
|
;; Decrement on down/j
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :count dec) nil]
|
{:model (update model :count dec)}
|
||||||
|
|
||||||
;; Reset on r
|
;; Reset on r
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :count 0) nil]
|
{:model (assoc model :count 0)}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))
|
{:model model}))
|
||||||
|
|
||||||
;; === View ===
|
;; === View ===
|
||||||
(defn view [{:keys [count]} _size]
|
(defn view [{:keys [count]} _size]
|
||||||
@@ -51,6 +52,6 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting counter...")
|
(println "Starting counter...")
|
||||||
(let [final-model (tui/run {:init initial-model
|
(let [final-model (tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view})]
|
:view view})]
|
||||||
(println "Final count:" (:count final-model))))
|
(println "Final count:" (:count final-model))))
|
||||||
|
|||||||
+20
-25
@@ -2,6 +2,7 @@
|
|||||||
"HTTP request example - demonstrates async commands.
|
"HTTP request example - demonstrates async commands.
|
||||||
Mirrors bubbletea's http example."
|
Mirrors bubbletea's http example."
|
||||||
(:require [tui.core :as tui]
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
[clojure.java.io :as io])
|
[clojure.java.io :as io])
|
||||||
(:import [java.net URL HttpURLConnection]))
|
(:import [java.net URL HttpURLConnection]))
|
||||||
|
|
||||||
@@ -29,41 +30,35 @@
|
|||||||
:error nil
|
:error nil
|
||||||
:url "https://httpstat.us/200"})
|
:url "https://httpstat.us/200"})
|
||||||
|
|
||||||
;; === Commands ===
|
|
||||||
(defn fetch-url [url]
|
|
||||||
(fn []
|
|
||||||
(let [result (http-get url)]
|
|
||||||
(if (:error result)
|
|
||||||
[:http-error (:error result)]
|
|
||||||
[:http-success (:status result)]))))
|
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [{:keys [url] :as model} msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
|
(let [{:keys [url]} model]
|
||||||
(cond
|
(cond
|
||||||
;; Quit
|
;; Quit
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
;; Enter - start request
|
;; Enter - start request
|
||||||
(and (= (:state model) :idle)
|
(and (= (:state model) :idle)
|
||||||
(tui/key= msg :enter))
|
(ev/key= event :enter))
|
||||||
[(assoc model :state :loading) (fetch-url url)]
|
{:model (assoc model :state :loading)
|
||||||
|
:events [(ev/shell ["curl" "-s" "-o" "/dev/null" "-w" "%{http_code}" url]
|
||||||
|
{:type :http-result})]}
|
||||||
|
|
||||||
;; r - retry/reset
|
;; r - retry/reset
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :state :idle :status nil :error nil) nil]
|
{:model (assoc model :state :idle :status nil :error nil)}
|
||||||
|
|
||||||
;; HTTP success
|
;; HTTP result
|
||||||
(= (first msg) :http-success)
|
(= (:type event) :http-result)
|
||||||
[(assoc model :state :success :status (second msg)) nil]
|
(let [{:keys [success out err]} (:result event)]
|
||||||
|
(if success
|
||||||
;; HTTP error
|
{:model (assoc model :state :success :status (parse-long out))}
|
||||||
(= (first msg) :http-error)
|
{:model (assoc model :state :error :error err)}))
|
||||||
[(assoc model :state :error :error (second msg)) nil]
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))
|
{:model model})))
|
||||||
|
|
||||||
;; === View ===
|
;; === View ===
|
||||||
(defn view [{:keys [state status error url]} _size]
|
(defn view [{:keys [state status error url]} _size]
|
||||||
@@ -105,7 +100,7 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting HTTP demo...")
|
(println "Starting HTTP demo...")
|
||||||
(let [final (tui/run {:init initial-model
|
(let [final (tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view})]
|
:view view})]
|
||||||
(when (= (:state final) :success)
|
(when (= (:state final) :success)
|
||||||
(println "Request completed with status:" (:status final)))))
|
(println "Request completed with status:" (:status final)))))
|
||||||
|
|||||||
+20
-18
@@ -2,6 +2,7 @@
|
|||||||
"List selection example - demonstrates cursor navigation and multi-select.
|
"List selection example - demonstrates cursor navigation and multi-select.
|
||||||
Mirrors bubbletea's list examples."
|
Mirrors bubbletea's list examples."
|
||||||
(:require [tui.core :as tui]
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
[clojure.string :as str]))
|
[clojure.string :as str]))
|
||||||
|
|
||||||
;; === Model ===
|
;; === Model ===
|
||||||
@@ -12,37 +13,38 @@
|
|||||||
:submitted false})
|
:submitted false})
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [{:keys [cursor items] :as model} msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
|
(let [{:keys [cursor items]} model]
|
||||||
(cond
|
(cond
|
||||||
;; Quit
|
;; Quit
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
;; Move up
|
;; Move up
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :cursor #(max 0 (dec %))) nil]
|
{:model (update model :cursor #(max 0 (dec %)))}
|
||||||
|
|
||||||
;; Move down
|
;; Move down
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
|
||||||
|
|
||||||
;; Toggle selection
|
;; Toggle selection
|
||||||
(tui/key= msg " ")
|
(ev/key= event \space)
|
||||||
[(update model :selected
|
{:model (update model :selected
|
||||||
#(if (contains? % cursor)
|
#(if (contains? % cursor)
|
||||||
(disj % cursor)
|
(disj % cursor)
|
||||||
(conj % cursor)))
|
(conj % cursor)))}
|
||||||
nil]
|
|
||||||
|
|
||||||
;; Submit
|
;; Submit
|
||||||
(tui/key= msg :enter)
|
(ev/key= event :enter)
|
||||||
[(assoc model :submitted true) tui/quit]
|
{:model (assoc model :submitted true)
|
||||||
|
:events [(ev/quit)]}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))
|
{:model model})))
|
||||||
|
|
||||||
;; === View ===
|
;; === View ===
|
||||||
(defn view [{:keys [cursor items selected submitted]} _size]
|
(defn view [{:keys [cursor items selected submitted]} _size]
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting list selection...")
|
(println "Starting list selection...")
|
||||||
(let [{:keys [items selected submitted]} (tui/run {:init initial-model
|
(let [{:keys [items selected submitted]} (tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view})]
|
:view view})]
|
||||||
(when submitted
|
(when submitted
|
||||||
(println)
|
(println)
|
||||||
|
|||||||
+23
-21
@@ -1,7 +1,8 @@
|
|||||||
(ns examples.spinner
|
(ns examples.spinner
|
||||||
"Spinner example - demonstrates animated loading states.
|
"Spinner example - demonstrates animated loading states.
|
||||||
Mirrors bubbletea's spinner example."
|
Mirrors bubbletea's spinner example."
|
||||||
(:require [tui.core :as tui]))
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]))
|
||||||
|
|
||||||
;; === Spinner Frames ===
|
;; === Spinner Frames ===
|
||||||
(def spinner-styles
|
(def spinner-styles
|
||||||
@@ -21,42 +22,43 @@
|
|||||||
:style :dots
|
:style :dots
|
||||||
:loading true
|
:loading true
|
||||||
:message "Loading..."
|
:message "Loading..."
|
||||||
:styles (keys spinner-styles)
|
:styles (vec (keys spinner-styles))
|
||||||
:style-idx 0})
|
:style-idx 0})
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [{:keys [styles style-idx] :as model} msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
|
(let [{:keys [styles style-idx]} model]
|
||||||
(cond
|
(cond
|
||||||
;; Quit
|
;; Quit
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
;; Spinner frame - advance animation
|
;; Spinner frame - advance animation
|
||||||
(= msg :spinner-frame)
|
(= (:type event) :spinner-frame)
|
||||||
(if (:loading model)
|
(if (:loading model)
|
||||||
[(update model :frame inc) (tui/after 80 :spinner-frame)]
|
{:model (update model :frame inc)
|
||||||
[model nil])
|
:events [(ev/delayed-event 80 {:type :spinner-frame})]}
|
||||||
|
{:model model})
|
||||||
|
|
||||||
;; Space - simulate completion
|
;; Space - simulate completion
|
||||||
(tui/key= msg " ")
|
(ev/key= event \space)
|
||||||
[(assoc model :loading false :message "Done!") nil]
|
{:model (assoc model :loading false :message "Done!")}
|
||||||
|
|
||||||
;; Tab - change spinner style
|
;; Tab - change spinner style
|
||||||
(tui/key= msg :tab)
|
(ev/key= event :tab)
|
||||||
(let [new-idx (mod (inc style-idx) (count styles))]
|
(let [new-idx (mod (inc style-idx) (count styles))]
|
||||||
[(assoc model
|
{:model (assoc model
|
||||||
:style-idx new-idx
|
:style-idx new-idx
|
||||||
:style (nth styles new-idx))
|
:style (nth styles new-idx))})
|
||||||
nil])
|
|
||||||
|
|
||||||
;; r - restart
|
;; r - restart
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :loading true :frame 0 :message "Loading...")
|
{:model (assoc model :loading true :frame 0 :message "Loading...")
|
||||||
(tui/after 80 :spinner-frame)]
|
:events [(ev/delayed-event 80 {:type :spinner-frame})]}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))
|
{:model model})))
|
||||||
|
|
||||||
;; === View ===
|
;; === View ===
|
||||||
(defn spinner-view [{:keys [frame style]}]
|
(defn spinner-view [{:keys [frame style]}]
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting spinner...")
|
(println "Starting spinner...")
|
||||||
(tui/run {:init initial-model
|
(tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view
|
:view view
|
||||||
:init-cmd (tui/after 80 :spinner-frame)})
|
:init-events [(ev/delayed-event 80 {:type :spinner-frame})]})
|
||||||
(println "Spinner demo finished."))
|
(println "Spinner demo finished."))
|
||||||
|
|||||||
+20
-18
@@ -1,7 +1,8 @@
|
|||||||
(ns examples.timer
|
(ns examples.timer
|
||||||
"Countdown timer example - demonstrates async commands.
|
"Countdown timer example - demonstrates async commands.
|
||||||
Mirrors bubbletea's stopwatch/timer examples."
|
Mirrors bubbletea's stopwatch/timer examples."
|
||||||
(:require [tui.core :as tui]))
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]))
|
||||||
|
|
||||||
;; === Model ===
|
;; === Model ===
|
||||||
(def initial-model
|
(def initial-model
|
||||||
@@ -10,37 +11,38 @@
|
|||||||
:done false})
|
:done false})
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [model msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
;; Quit
|
;; Quit
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
;; Timer tick - decrement timer
|
;; Timer tick - decrement timer
|
||||||
(= msg :timer-tick)
|
(= (:type event) :timer-tick)
|
||||||
(if (:running model)
|
(if (:running model)
|
||||||
(let [new-seconds (dec (:seconds model))]
|
(let [new-seconds (dec (:seconds model))]
|
||||||
(if (<= new-seconds 0)
|
(if (<= new-seconds 0)
|
||||||
;; Timer done
|
;; Timer done
|
||||||
[(assoc model :seconds 0 :done true :running false) nil]
|
{:model (assoc model :seconds 0 :done true :running false)}
|
||||||
;; Continue countdown
|
;; Continue countdown
|
||||||
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)]))
|
{:model (assoc model :seconds new-seconds)
|
||||||
[model nil])
|
:events [(ev/delayed-event 1000 {:type :timer-tick})]}))
|
||||||
|
{:model model})
|
||||||
|
|
||||||
;; Space - pause/resume
|
;; Space - pause/resume
|
||||||
(tui/key= msg " ")
|
(ev/key= event \space)
|
||||||
(let [new-running (not (:running model))]
|
(let [new-running (not (:running model))]
|
||||||
[(assoc model :running new-running)
|
{:model (assoc model :running new-running)
|
||||||
(when new-running (tui/after 1000 :timer-tick))])
|
:events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
|
||||||
|
|
||||||
;; r - reset
|
;; r - reset
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :seconds 10 :done false :running true)
|
{:model (assoc model :seconds 10 :done false :running true)
|
||||||
(tui/after 1000 :timer-tick)]
|
:events [(ev/delayed-event 1000 {:type :timer-tick})]}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))
|
{:model model}))
|
||||||
|
|
||||||
;; === View ===
|
;; === View ===
|
||||||
(defn format-time [seconds]
|
(defn format-time [seconds]
|
||||||
@@ -74,8 +76,8 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting timer...")
|
(println "Starting timer...")
|
||||||
(let [final-model (tui/run {:init initial-model
|
(let [final-model (tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view
|
:view view
|
||||||
:init-cmd (tui/after 1000 :timer-tick)})]
|
:init-events [(ev/delayed-event 1000 {:type :timer-tick})]})]
|
||||||
(when (:done final-model)
|
(when (:done final-model)
|
||||||
(println "Timer completed!"))))
|
(println "Timer completed!"))))
|
||||||
|
|||||||
+31
-30
@@ -1,7 +1,8 @@
|
|||||||
(ns examples.views
|
(ns examples.views
|
||||||
"Multiple views example - demonstrates state machine pattern.
|
"Multiple views example - demonstrates state machine pattern.
|
||||||
Mirrors bubbletea's views example."
|
Mirrors bubbletea's views example."
|
||||||
(:require [tui.core :as tui]))
|
(:require [tui.core :as tui]
|
||||||
|
[tui.events :as ev]))
|
||||||
|
|
||||||
;; === Model ===
|
;; === Model ===
|
||||||
(def initial-model
|
(def initial-model
|
||||||
@@ -14,58 +15,58 @@
|
|||||||
:selected nil})
|
:selected nil})
|
||||||
|
|
||||||
;; === Update ===
|
;; === Update ===
|
||||||
(defn update-model [{:keys [view cursor items] :as model} msg]
|
(defn update-fn [{:keys [model event]}]
|
||||||
|
(let [{:keys [view cursor items]} model]
|
||||||
(case view
|
(case view
|
||||||
;; Menu view
|
;; Menu view
|
||||||
:menu
|
:menu
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :cursor #(max 0 (dec %))) nil]
|
{:model (update model :cursor #(max 0 (dec %)))}
|
||||||
|
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
|
||||||
|
|
||||||
(tui/key= msg :enter)
|
(ev/key= event :enter)
|
||||||
[(assoc model
|
{:model (assoc model
|
||||||
:view :detail
|
:view :detail
|
||||||
:selected (nth items cursor))
|
:selected (nth items cursor))}
|
||||||
nil]
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil])
|
{:model model})
|
||||||
|
|
||||||
;; Detail view
|
;; Detail view
|
||||||
:detail
|
:detail
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[(assoc model :view :confirm) nil]
|
{:model (assoc model :view :confirm)}
|
||||||
|
|
||||||
(or (tui/key= msg :escape)
|
(or (ev/key= event :escape)
|
||||||
(tui/key= msg "b"))
|
(ev/key= event \b))
|
||||||
[(assoc model :view :menu :selected nil) nil]
|
{:model (assoc model :view :menu :selected nil)}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil])
|
{:model model})
|
||||||
|
|
||||||
;; Confirm quit dialog
|
;; Confirm quit dialog
|
||||||
:confirm
|
:confirm
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg "y")
|
(ev/key= event \y)
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
(or (tui/key= msg "n")
|
(or (ev/key= event \n)
|
||||||
(tui/key= msg :escape))
|
(ev/key= event :escape))
|
||||||
[(assoc model :view :detail) nil]
|
{:model (assoc model :view :detail)}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil])))
|
{:model model}))))
|
||||||
|
|
||||||
;; === Views ===
|
;; === Views ===
|
||||||
(defn menu-view [{:keys [cursor items]}]
|
(defn menu-view [{:keys [cursor items]}]
|
||||||
@@ -112,6 +113,6 @@
|
|||||||
(defn -main [& _args]
|
(defn -main [& _args]
|
||||||
(println "Starting views demo...")
|
(println "Starting views demo...")
|
||||||
(tui/run {:init initial-model
|
(tui/run {:init initial-model
|
||||||
:update update-model
|
:update update-fn
|
||||||
:view view})
|
:view view})
|
||||||
(println "Views demo finished."))
|
(println "Views demo finished."))
|
||||||
|
|||||||
@@ -236,3 +236,13 @@
|
|||||||
true
|
true
|
||||||
active-styles))))))))]
|
active-styles))))))))]
|
||||||
(apply str result)))))
|
(apply str result)))))
|
||||||
|
|
||||||
|
(defn fit-width
|
||||||
|
"Fit string to exactly the given width - truncate if too long, pad if too short.
|
||||||
|
Unlike truncate, this does not add ellipsis - it hard clips the content."
|
||||||
|
[s width]
|
||||||
|
(let [vlen (visible-length s)]
|
||||||
|
(cond
|
||||||
|
(= vlen width) s
|
||||||
|
(> vlen width) (visible-subs s 0 width)
|
||||||
|
:else (str s (apply str (repeat (- width vlen) " "))))))
|
||||||
|
|||||||
+26
-163
@@ -1,68 +1,29 @@
|
|||||||
(ns tui.core
|
(ns tui.core
|
||||||
"Core TUI framework - Elm architecture runtime.
|
"Core TUI framework - Elm architecture runtime.
|
||||||
|
|
||||||
## New API (Recommended)
|
|
||||||
|
|
||||||
Update function signature:
|
Update function signature:
|
||||||
(fn [{:keys [model event]}]
|
(fn [{:keys [model event]}]
|
||||||
{:model new-model
|
{:model new-model
|
||||||
:events [...]}) ; :events is optional
|
:events [...]}) ; :events is optional
|
||||||
|
|
||||||
Events are maps with :type discriminator. See tui.events namespace.
|
Events are maps with :type discriminator. See tui.events namespace."
|
||||||
|
|
||||||
## Legacy API (Deprecated)
|
|
||||||
|
|
||||||
For backward compatibility, the old signature is still supported:
|
|
||||||
(fn [model msg] [new-model cmd])
|
|
||||||
|
|
||||||
The runtime auto-detects which format is being used."
|
|
||||||
(:require [tui.terminal :as term]
|
(:require [tui.terminal :as term]
|
||||||
[tui.input :as input]
|
[tui.input :as input]
|
||||||
[tui.render :as render]
|
[tui.render :as render]
|
||||||
[tui.ansi :as ansi]
|
[tui.events :as ev]
|
||||||
[clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt! alt!!]]))
|
[clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt! alt!!]]))
|
||||||
|
|
||||||
;; === Legacy Command Types (Deprecated) ===
|
;; Re-export commonly used functions from tui.events for convenience
|
||||||
;; nil - no-op
|
(def quit ev/quit)
|
||||||
;; [:quit] - exit program
|
(def delayed-event ev/delayed-event)
|
||||||
;; [:batch cmd1 cmd2 ...] - run commands in parallel
|
(def batch ev/batch)
|
||||||
;; [:seq cmd1 cmd2 ...] - run commands sequentially
|
(def sequential ev/sequential)
|
||||||
;; (fn [] msg) - arbitrary async function returning message
|
(def shell ev/shell)
|
||||||
|
(def debounce ev/debounce)
|
||||||
|
(def key= ev/key=)
|
||||||
|
|
||||||
;; === Legacy Built-in Commands (Deprecated) ===
|
;; Re-export render function
|
||||||
(def quit
|
(def render render/render)
|
||||||
"DEPRECATED: Use (tui.events/quit) instead."
|
|
||||||
[:quit])
|
|
||||||
|
|
||||||
(defn after
|
|
||||||
"DEPRECATED: Use (tui.events/delay ms event) instead.
|
|
||||||
|
|
||||||
Returns a command that sends msg after ms milliseconds."
|
|
||||||
[ms msg]
|
|
||||||
(fn []
|
|
||||||
(Thread/sleep ms)
|
|
||||||
msg))
|
|
||||||
|
|
||||||
(defn batch
|
|
||||||
"DEPRECATED: Use (tui.events/batch ...) instead.
|
|
||||||
|
|
||||||
Run multiple commands in parallel."
|
|
||||||
[& cmds]
|
|
||||||
(into [:batch] (remove nil? cmds)))
|
|
||||||
|
|
||||||
(defn sequentially
|
|
||||||
"DEPRECATED: Use (tui.events/sequential ...) instead.
|
|
||||||
|
|
||||||
Run multiple commands sequentially."
|
|
||||||
[& cmds]
|
|
||||||
(into [:seq] (remove nil? cmds)))
|
|
||||||
|
|
||||||
(defn send-msg
|
|
||||||
"DEPRECATED: Put event directly in :events vector instead.
|
|
||||||
|
|
||||||
Create a command that sends a message."
|
|
||||||
[msg]
|
|
||||||
(fn [] msg))
|
|
||||||
|
|
||||||
;; === Debounce State ===
|
;; === Debounce State ===
|
||||||
(def ^:private debounce-timers (atom {}))
|
(def ^:private debounce-timers (atom {}))
|
||||||
@@ -70,7 +31,7 @@
|
|||||||
;; === Event Execution ===
|
;; === Event Execution ===
|
||||||
(defn- execute-event!
|
(defn- execute-event!
|
||||||
"Execute an event, putting resulting events on the channel.
|
"Execute an event, putting resulting events on the channel.
|
||||||
Handles runtime events (:quit, :delay, :shell, :batch, :sequential, :debounce).
|
Handles runtime events (:quit, :delayed-event, :shell, :batch, :sequential, :debounce).
|
||||||
Unknown event types are dispatched back to the update function."
|
Unknown event types are dispatched back to the update function."
|
||||||
[event msg-chan]
|
[event msg-chan]
|
||||||
(when event
|
(when event
|
||||||
@@ -80,8 +41,8 @@
|
|||||||
:quit
|
:quit
|
||||||
(put! msg-chan {:type :quit})
|
(put! msg-chan {:type :quit})
|
||||||
|
|
||||||
;; Delay - wait then dispatch event
|
;; Delayed event - wait then dispatch event
|
||||||
:delay
|
:delayed-event
|
||||||
(let [{:keys [ms event]} event]
|
(let [{:keys [ms event]} event]
|
||||||
(go
|
(go
|
||||||
(<! (timeout ms))
|
(<! (timeout ms))
|
||||||
@@ -148,41 +109,6 @@
|
|||||||
;; Unknown type - dispatch to update function
|
;; Unknown type - dispatch to update function
|
||||||
(put! msg-chan event)))))
|
(put! msg-chan event)))))
|
||||||
|
|
||||||
;; === Legacy Command Execution ===
|
|
||||||
(defn- execute-cmd!
|
|
||||||
"Execute a legacy command, putting resulting messages on the channel."
|
|
||||||
[cmd msg-chan]
|
|
||||||
(when cmd
|
|
||||||
(cond
|
|
||||||
;; Quit command
|
|
||||||
(= cmd [:quit])
|
|
||||||
(put! msg-chan {:type :quit})
|
|
||||||
|
|
||||||
;; Batch - run all in parallel
|
|
||||||
(and (vector? cmd) (= (first cmd) :batch))
|
|
||||||
(doseq [c (rest cmd)]
|
|
||||||
(execute-cmd! c msg-chan))
|
|
||||||
|
|
||||||
;; Sequence - run one after another
|
|
||||||
(and (vector? cmd) (= (first cmd) :seq))
|
|
||||||
(go-loop [[c & rest-cmds] (rest cmd)]
|
|
||||||
(when c
|
|
||||||
(let [result-chan (chan 1)]
|
|
||||||
(execute-cmd! c result-chan)
|
|
||||||
(when-let [msg (<! result-chan)]
|
|
||||||
(>! msg-chan msg)
|
|
||||||
(recur rest-cmds)))))
|
|
||||||
|
|
||||||
;; Function - execute and send result
|
|
||||||
(fn? cmd)
|
|
||||||
(go
|
|
||||||
(let [msg (cmd)]
|
|
||||||
(when msg
|
|
||||||
(>! msg-chan msg))))
|
|
||||||
|
|
||||||
:else
|
|
||||||
nil)))
|
|
||||||
|
|
||||||
;; === Input Loop ===
|
;; === Input Loop ===
|
||||||
(defn- start-input-loop!
|
(defn- start-input-loop!
|
||||||
"Start thread that reads input and puts events on channel.
|
"Start thread that reads input and puts events on channel.
|
||||||
@@ -198,61 +124,24 @@
|
|||||||
(Thread/sleep 10))
|
(Thread/sleep 10))
|
||||||
(recur)))))
|
(recur)))))
|
||||||
|
|
||||||
;; === Update Function Detection ===
|
|
||||||
(defn- detect-update-format
|
|
||||||
"Detect if update function uses new or legacy format by examining its signature.
|
|
||||||
Returns :new or :legacy."
|
|
||||||
[update-fn]
|
|
||||||
;; We can't easily detect at compile time, so we'll detect at runtime
|
|
||||||
;; by checking the result format
|
|
||||||
:unknown)
|
|
||||||
|
|
||||||
(defn- call-update
|
|
||||||
"Call update function, handling both new and legacy formats.
|
|
||||||
Returns {:model m :events [...]} in new format."
|
|
||||||
[update-fn model event legacy-mode?]
|
|
||||||
(if legacy-mode?
|
|
||||||
;; Legacy: (fn [model msg] [new-model cmd])
|
|
||||||
(let [[new-model cmd] (update-fn model event)]
|
|
||||||
{:model new-model
|
|
||||||
:legacy-cmd cmd})
|
|
||||||
;; New: (fn [{:keys [model event]}] {:model m :events [...]})
|
|
||||||
(let [result (update-fn {:model model :event event})]
|
|
||||||
(if (vector? result)
|
|
||||||
;; Got legacy format back, switch to legacy mode
|
|
||||||
{:model (first result)
|
|
||||||
:legacy-cmd (second result)
|
|
||||||
:switch-to-legacy true}
|
|
||||||
;; New format
|
|
||||||
result))))
|
|
||||||
|
|
||||||
;; === Main Run Loop ===
|
;; === Main Run Loop ===
|
||||||
(defn run
|
(defn run
|
||||||
"Run a TUI application.
|
"Run a TUI application.
|
||||||
|
|
||||||
## New API (Recommended)
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- :init - Initial model (required)
|
- :init - Initial model (required)
|
||||||
- :update - (fn [{:keys [model event]}] {:model m :events [...]}) (required)
|
- :update - (fn [{:keys [model event]}] {:model m :events [...]}) (required)
|
||||||
- :view - (fn [model size] hiccup) where size is {:width w :height h} (required)
|
- :view - (fn [model size] hiccup) where size is {:width w :height h} (required)
|
||||||
|
- :init-events - Vector of events to dispatch at startup
|
||||||
- :fps - Target frames per second (default 60)
|
- :fps - Target frames per second (default 60)
|
||||||
- :alt-screen - Use alternate screen buffer (default true)
|
- :alt-screen - Use alternate screen buffer (default true)
|
||||||
|
|
||||||
## Legacy API (Deprecated)
|
|
||||||
|
|
||||||
Also accepts:
|
|
||||||
- :update - (fn [model msg] [new-model cmd])
|
|
||||||
- :init-cmd - Initial command to run
|
|
||||||
|
|
||||||
Returns the final model."
|
Returns the final model."
|
||||||
[{:keys [init update view init-cmd init-events fps alt-screen]
|
[{:keys [init update view init-events fps alt-screen]
|
||||||
:or {fps 60 alt-screen true}}]
|
:or {fps 60 alt-screen true}}]
|
||||||
(let [msg-chan (chan 256)
|
(let [msg-chan (chan 256)
|
||||||
running? (atom true)
|
running? (atom true)
|
||||||
frame-time (/ 1000 fps)
|
frame-time (/ 1000 fps)]
|
||||||
;; Start in auto-detect mode, will switch to legacy if needed
|
|
||||||
legacy-mode? (atom false)]
|
|
||||||
|
|
||||||
;; Setup terminal
|
;; Setup terminal
|
||||||
(term/raw-mode!)
|
(term/raw-mode!)
|
||||||
@@ -264,12 +153,10 @@
|
|||||||
;; Start input loop
|
;; Start input loop
|
||||||
(start-input-loop! msg-chan running?)
|
(start-input-loop! msg-chan running?)
|
||||||
|
|
||||||
;; Execute initial events/command
|
;; Execute initial events
|
||||||
(when init-events
|
(when init-events
|
||||||
(doseq [event init-events]
|
(doseq [event init-events]
|
||||||
(execute-event! event msg-chan)))
|
(execute-event! event msg-chan)))
|
||||||
(when init-cmd
|
|
||||||
(execute-cmd! init-cmd msg-chan))
|
|
||||||
|
|
||||||
;; Initial render
|
;; Initial render
|
||||||
(let [size (term/get-terminal-size)
|
(let [size (term/get-terminal-size)
|
||||||
@@ -291,27 +178,22 @@
|
|||||||
(recur model (System/currentTimeMillis))
|
(recur model (System/currentTimeMillis))
|
||||||
|
|
||||||
;; Check for quit
|
;; Check for quit
|
||||||
(if (or (= event {:type :quit})
|
(if (= (:type event) :quit)
|
||||||
(= event [:quit])) ; legacy
|
|
||||||
;; Quit - return final model
|
;; Quit - return final model
|
||||||
model
|
model
|
||||||
|
|
||||||
;; Update model
|
;; Update model
|
||||||
(let [result (call-update update model event @legacy-mode?)
|
(let [result (update {:model model :event event})
|
||||||
_ (when (:switch-to-legacy result)
|
|
||||||
(reset! legacy-mode? true))
|
|
||||||
new-model (:model result)
|
new-model (:model result)
|
||||||
size (term/get-terminal-size)
|
size (term/get-terminal-size)
|
||||||
ctx {:available-height (:height size)
|
ctx {:available-height (:height size)
|
||||||
:available-width (:width size)}
|
:available-width (:width size)}
|
||||||
now (System/currentTimeMillis)]
|
now (System/currentTimeMillis)]
|
||||||
|
|
||||||
;; Execute events (new API) or command (legacy)
|
;; Execute events
|
||||||
(if-let [events (:events result)]
|
(when-let [events (:events result)]
|
||||||
(doseq [e events]
|
(doseq [e events]
|
||||||
(execute-event! e msg-chan))
|
(execute-event! e msg-chan)))
|
||||||
(when-let [cmd (:legacy-cmd result)]
|
|
||||||
(execute-cmd! cmd msg-chan)))
|
|
||||||
|
|
||||||
;; Render with context for flex layouts
|
;; Render with context for flex layouts
|
||||||
(term/render! (render/render (view new-model size) ctx))
|
(term/render! (render/render (view new-model size) ctx))
|
||||||
@@ -335,30 +217,11 @@
|
|||||||
|
|
||||||
(defapp my-app
|
(defapp my-app
|
||||||
:init {:count 0}
|
:init {:count 0}
|
||||||
:update (fn [ctx] ...)
|
:update (fn [{:keys [model event]}] ...)
|
||||||
:view (fn [model size] ...))"
|
:view (fn [model size] ...))"
|
||||||
[name & {:keys [init update view init-cmd init-events]}]
|
[name & {:keys [init update view init-events]}]
|
||||||
`(def ~name
|
`(def ~name
|
||||||
{:init ~init
|
{:init ~init
|
||||||
:update ~update
|
:update ~update
|
||||||
:view ~view
|
:view ~view
|
||||||
:init-cmd ~init-cmd
|
|
||||||
:init-events ~init-events}))
|
:init-events ~init-events}))
|
||||||
|
|
||||||
;; === Legacy Key Matching Helpers (Deprecated) ===
|
|
||||||
(defn key=
|
|
||||||
"DEPRECATED: Use tui.events/key= instead.
|
|
||||||
|
|
||||||
Check if message is a specific key."
|
|
||||||
[msg key-pattern]
|
|
||||||
(input/key-match? msg key-pattern))
|
|
||||||
|
|
||||||
(defn key-str
|
|
||||||
"DEPRECATED: Use tui.input/key->str instead.
|
|
||||||
|
|
||||||
Get string representation of key."
|
|
||||||
[msg]
|
|
||||||
(input/key->str msg))
|
|
||||||
|
|
||||||
;; Re-export render function
|
|
||||||
(def render render/render)
|
|
||||||
|
|||||||
+13
-10
@@ -15,7 +15,7 @@
|
|||||||
{:type :key, :key :enter} ; special key
|
{:type :key, :key :enter} ; special key
|
||||||
{:type :key, :key \\c, :modifiers #{:ctrl}} ; with modifiers
|
{:type :key, :key \\c, :modifiers #{:ctrl}} ; with modifiers
|
||||||
{:type :quit} ; quit app
|
{:type :quit} ; quit app
|
||||||
{:type :delay, :ms 2000, :event {...}} ; delayed event
|
{:type :delayed-event, :ms 2000, :event {...}} ; delayed event
|
||||||
|
|
||||||
## Update Function Contract
|
## Update Function Contract
|
||||||
|
|
||||||
@@ -126,18 +126,21 @@
|
|||||||
[]
|
[]
|
||||||
{:type :quit})
|
{:type :quit})
|
||||||
|
|
||||||
(defn delay
|
(defn delayed-event
|
||||||
"Create an event that dispatches another event after a delay.
|
"Create an event that dispatches another event after a delay.
|
||||||
|
|
||||||
The nested event is dispatched after the specified milliseconds elapse.
|
Like JavaScript's setTimeout - schedules an event to be dispatched
|
||||||
Useful for transient messages, animations, debouncing, or timeouts.
|
after the specified milliseconds elapse. Useful for transient messages,
|
||||||
|
animations, debouncing, or timeouts.
|
||||||
|
|
||||||
|
Note: Named delayed-event to avoid conflict with clojure.core/delay.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
ms - Delay in milliseconds before dispatching
|
ms - Delay in milliseconds before dispatching
|
||||||
event - Event map to dispatch after the delay
|
event - Event map to dispatch after the delay
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{:type :delay, :ms <ms>, :event <event>}
|
{:type :delayed-event, :ms <ms>, :event <event>}
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
;; Show a message that auto-clears after 3 seconds
|
;; Show a message that auto-clears after 3 seconds
|
||||||
@@ -145,7 +148,7 @@
|
|||||||
(case (:type event)
|
(case (:type event)
|
||||||
:show-message
|
:show-message
|
||||||
{:model (assoc model :message (:text event))
|
{:model (assoc model :message (:text event))
|
||||||
:events [(delay 3000 {:type :clear-message})]}
|
:events [(delayed-event 3000 {:type :clear-message})]}
|
||||||
|
|
||||||
:clear-message
|
:clear-message
|
||||||
{:model (dissoc model :message)}
|
{:model (dissoc model :message)}
|
||||||
@@ -158,13 +161,13 @@
|
|||||||
{:model (-> model
|
{:model (-> model
|
||||||
(update :buffer conj event)
|
(update :buffer conj event)
|
||||||
(assoc :dirty true))
|
(assoc :dirty true))
|
||||||
:events [(delay 5000 {:type :auto-save})]}
|
:events [(delayed-event 5000 {:type :auto-save})]}
|
||||||
{:model model}))
|
{:model model}))
|
||||||
|
|
||||||
;; Simple animation frame
|
;; Simple animation frame
|
||||||
{:events [(delay 16 {:type :animation-tick})]}"
|
{:events [(delayed-event 16 {:type :animation-tick})]}"
|
||||||
[ms event]
|
[ms event]
|
||||||
{:type :delay, :ms ms, :event event})
|
{:type :delayed-event, :ms ms, :event event})
|
||||||
|
|
||||||
(defn shell
|
(defn shell
|
||||||
"Create an event that runs a shell command asynchronously.
|
"Create an event that runs a shell command asynchronously.
|
||||||
@@ -274,7 +277,7 @@
|
|||||||
;; Show message, wait, then clear
|
;; Show message, wait, then clear
|
||||||
(sequential
|
(sequential
|
||||||
{:type :show-message, :text \"Saved!\"}
|
{:type :show-message, :text \"Saved!\"}
|
||||||
(delay 2000 {:type :clear-message}))
|
(delayed-event 2000 {:type :clear-message}))
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
For complex workflows, consider handling each step explicitly
|
For complex workflows, consider handling each step explicitly
|
||||||
|
|||||||
+3
-3
@@ -286,10 +286,10 @@
|
|||||||
box-width (or target-width (+ (max inner-width title-width) 2))
|
box-width (or target-width (+ (max inner-width title-width) 2))
|
||||||
content-width (- box-width 2)
|
content-width (- box-width 2)
|
||||||
|
|
||||||
;; Pad lines
|
;; Pad lines (and truncate if too long)
|
||||||
padded-lines (for [line lines]
|
padded-lines (for [line lines]
|
||||||
(str (apply str (repeat pad-left " "))
|
(str (apply str (repeat pad-left " "))
|
||||||
(ansi/pad-right line (- content-width pad-left pad-right))
|
(ansi/fit-width line (- content-width pad-left pad-right))
|
||||||
(apply str (repeat pad-right " "))))
|
(apply str (repeat pad-right " "))))
|
||||||
|
|
||||||
;; Add vertical padding
|
;; Add vertical padding
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
(:br chars))
|
(:br chars))
|
||||||
body-lines (for [line all-lines]
|
body-lines (for [line all-lines]
|
||||||
(str (:v chars)
|
(str (:v chars)
|
||||||
(ansi/pad-right line content-width)
|
(ansi/fit-width line content-width)
|
||||||
(:v chars)))]
|
(:v chars)))]
|
||||||
(str/join "\n" (concat [top-line] body-lines [bottom-line]))))
|
(str/join "\n" (concat [top-line] body-lines [bottom-line]))))
|
||||||
|
|
||||||
|
|||||||
+232
-269
@@ -4,237 +4,221 @@
|
|||||||
(:require [clojure.test :refer [deftest testing is are]]
|
(:require [clojure.test :refer [deftest testing is are]]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[tui.core :as tui]
|
[tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
[tui.render :as render]
|
[tui.render :as render]
|
||||||
[tui.input :as input]))
|
[tui.input :as input]))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; KEY MATCHING TESTS (tui/key=)
|
;; KEY MATCHING TESTS (ev/key=)
|
||||||
;; Patterns from: counter, timer, list-selection, spinner, views, http
|
;; Patterns from: counter, timer, list-selection, spinner, views, http
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest key=-character-keys-test
|
(deftest key=-character-keys-test
|
||||||
(testing "from counter: matching q for quit"
|
(testing "from counter: matching q for quit"
|
||||||
(is (tui/key= [:key {:char \q}] "q"))
|
(is (ev/key= {:type :key :key \q} \q))
|
||||||
(is (not (tui/key= [:key {:char \a}] "q"))))
|
(is (not (ev/key= {:type :key :key \a} \q))))
|
||||||
|
|
||||||
(testing "from counter: matching k/j for navigation"
|
(testing "from counter: matching k/j for navigation"
|
||||||
(is (tui/key= [:key {:char \k}] "k"))
|
(is (ev/key= {:type :key :key \k} \k))
|
||||||
(is (tui/key= [:key {:char \j}] "j")))
|
(is (ev/key= {:type :key :key \j} \j)))
|
||||||
|
|
||||||
(testing "from counter: matching r for reset"
|
(testing "from counter: matching r for reset"
|
||||||
(is (tui/key= [:key {:char \r}] "r")))
|
(is (ev/key= {:type :key :key \r} \r)))
|
||||||
|
|
||||||
(testing "from timer: matching space for pause/resume"
|
(testing "from timer: matching space for pause/resume"
|
||||||
(is (tui/key= [:key {:char \space}] " "))
|
(is (ev/key= {:type :key :key \space} \space)))
|
||||||
(is (tui/key= [:key {:char \space}] " ")))
|
|
||||||
|
|
||||||
(testing "from views: matching b for back, y/n for confirm"
|
(testing "from views: matching b for back, y/n for confirm"
|
||||||
(is (tui/key= [:key {:char \b}] "b"))
|
(is (ev/key= {:type :key :key \b} \b))
|
||||||
(is (tui/key= [:key {:char \y}] "y"))
|
(is (ev/key= {:type :key :key \y} \y))
|
||||||
(is (tui/key= [:key {:char \n}] "n"))))
|
(is (ev/key= {:type :key :key \n} \n))))
|
||||||
|
|
||||||
(deftest key=-arrow-keys-test
|
(deftest key=-arrow-keys-test
|
||||||
(testing "from counter/list-selection: up/down arrows"
|
(testing "from counter/list-selection: up/down arrows"
|
||||||
(is (tui/key= [:key :up] :up))
|
(is (ev/key= {:type :key :key :up} :up))
|
||||||
(is (tui/key= [:key :down] :down))
|
(is (ev/key= {:type :key :key :down} :down))
|
||||||
(is (not (tui/key= [:key :up] :down)))
|
(is (not (ev/key= {:type :key :key :up} :down)))
|
||||||
(is (not (tui/key= [:key :left] :up))))
|
(is (not (ev/key= {:type :key :key :left} :up))))
|
||||||
|
|
||||||
(testing "left/right arrows"
|
(testing "left/right arrows"
|
||||||
(is (tui/key= [:key :left] :left))
|
(is (ev/key= {:type :key :key :left} :left))
|
||||||
(is (tui/key= [:key :right] :right))))
|
(is (ev/key= {:type :key :key :right} :right))))
|
||||||
|
|
||||||
(deftest key=-special-keys-test
|
(deftest key=-special-keys-test
|
||||||
(testing "from list-selection/http: enter key"
|
(testing "from list-selection/http: enter key"
|
||||||
(is (tui/key= [:key :enter] :enter)))
|
(is (ev/key= {:type :key :key :enter} :enter)))
|
||||||
|
|
||||||
(testing "from views: escape key"
|
(testing "from views: escape key"
|
||||||
(is (tui/key= [:key :escape] :escape)))
|
(is (ev/key= {:type :key :key :escape} :escape)))
|
||||||
|
|
||||||
(testing "from spinner: tab key"
|
(testing "from spinner: tab key"
|
||||||
(is (tui/key= [:key :tab] :tab)))
|
(is (ev/key= {:type :key :key :tab} :tab)))
|
||||||
|
|
||||||
(testing "backspace key"
|
(testing "backspace key"
|
||||||
(is (tui/key= [:key :backspace] :backspace))))
|
(is (ev/key= {:type :key :key :backspace} :backspace))))
|
||||||
|
|
||||||
(deftest key=-ctrl-combos-test
|
(deftest key=-ctrl-combos-test
|
||||||
(testing "from all examples: ctrl+c for quit"
|
(testing "from all examples: ctrl+c for quit"
|
||||||
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c])))
|
(is (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c #{:ctrl})))
|
||||||
|
|
||||||
(testing "other ctrl combinations"
|
(testing "other ctrl combinations"
|
||||||
(is (tui/key= [:key {:ctrl true :char \x}] [:ctrl \x]))
|
(is (ev/key= {:type :key :key \x :modifiers #{:ctrl}} \x #{:ctrl}))
|
||||||
(is (tui/key= [:key {:ctrl true :char \z}] [:ctrl \z]))
|
(is (ev/key= {:type :key :key \z :modifiers #{:ctrl}} \z #{:ctrl}))
|
||||||
(is (tui/key= [:key {:ctrl true :char \a}] [:ctrl \a])))
|
(is (ev/key= {:type :key :key \a :modifiers #{:ctrl}} \a #{:ctrl})))
|
||||||
|
|
||||||
(testing "ctrl combo does not match plain char"
|
(testing "ctrl combo does not match plain char"
|
||||||
(is (not (tui/key= [:key {:ctrl true :char \c}] "c")))
|
(is (not (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c)))
|
||||||
(is (not (tui/key= [:key {:char \c}] [:ctrl \c])))))
|
(is (not (ev/key= {:type :key :key \c} \c #{:ctrl})))))
|
||||||
|
|
||||||
(deftest key=-alt-combos-test
|
(deftest key=-alt-combos-test
|
||||||
(testing "alt+char combinations"
|
(testing "alt+char combinations"
|
||||||
(is (tui/key= [:key {:alt true :char \x}] [:alt \x]))
|
(is (ev/key= {:type :key :key \x :modifiers #{:alt}} \x #{:alt}))
|
||||||
(is (tui/key= [:key {:alt true :char \a}] [:alt \a])))
|
(is (ev/key= {:type :key :key \a :modifiers #{:alt}} \a #{:alt})))
|
||||||
|
|
||||||
(testing "alt combo does not match plain char"
|
(testing "alt combo does not match plain char"
|
||||||
(is (not (tui/key= [:key {:alt true :char \x}] "x")))
|
(is (not (ev/key= {:type :key :key \x :modifiers #{:alt}} \x)))
|
||||||
(is (not (tui/key= [:key {:char \x}] [:alt \x])))))
|
(is (not (ev/key= {:type :key :key \x} \x #{:alt})))))
|
||||||
|
|
||||||
(deftest key=-non-key-messages-test
|
(deftest key=-non-key-events-test
|
||||||
(testing "from timer/spinner: tick messages are not keys"
|
(testing "from timer/spinner: custom events are not keys"
|
||||||
(is (not (tui/key= [:tick 123456789] "q")))
|
(is (not (ev/key= {:type :timer-tick} \q)))
|
||||||
(is (not (tui/key= [:tick 123456789] :enter))))
|
(is (not (ev/key= {:type :timer-tick} :enter))))
|
||||||
|
|
||||||
(testing "from http: custom messages are not keys"
|
(testing "from http: custom events are not keys"
|
||||||
(is (not (tui/key= [:http-success 200] "q")))
|
(is (not (ev/key= {:type :http-result :status 200} \q)))
|
||||||
(is (not (tui/key= [:http-error "timeout"] :enter))))
|
(is (not (ev/key= {:type :http-error :error "timeout"} :enter))))
|
||||||
|
|
||||||
(testing "quit command is not a key"
|
(testing "quit event is not a key"
|
||||||
(is (not (tui/key= [:quit] "q")))))
|
(is (not (ev/key= {:type :quit} \q)))))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; COMMAND TESTS
|
;; EVENT CONSTRUCTOR TESTS
|
||||||
;; Patterns from: timer, spinner, http
|
;; Patterns from: timer, spinner, http
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest quit-command-test
|
(deftest quit-event-test
|
||||||
(testing "from all examples: tui/quit is [:quit]"
|
(testing "from all examples: ev/quit creates quit event"
|
||||||
(is (= [:quit] tui/quit))
|
(is (= {:type :quit} (ev/quit)))
|
||||||
(is (vector? tui/quit))
|
(is (map? (ev/quit)))
|
||||||
(is (= :quit (first tui/quit)))))
|
(is (= :quit (:type (ev/quit))))))
|
||||||
|
|
||||||
(deftest after-command-test
|
(deftest delayed-event-test
|
||||||
(testing "from timer: after creates function"
|
(testing "from timer: delayed-event creates delayed-event event"
|
||||||
(let [cmd (tui/after 1000 :timer-tick)]
|
(let [event (ev/delayed-event 1000 {:type :timer-tick})]
|
||||||
(is (fn? cmd))))
|
(is (= :delayed-event (:type event)))
|
||||||
|
(is (= 1000 (:ms event)))
|
||||||
|
(is (= {:type :timer-tick} (:event event)))))
|
||||||
|
|
||||||
(testing "from spinner: after creates function"
|
(testing "from spinner: delayed-event with short interval"
|
||||||
(let [cmd (tui/after 80 :spinner-frame)]
|
(let [event (ev/delayed-event 80 {:type :spinner-frame})]
|
||||||
(is (fn? cmd))))
|
(is (= :delayed-event (:type event)))
|
||||||
|
(is (= 80 (:ms event))))))
|
||||||
|
|
||||||
(testing "after with zero delay returns message immediately"
|
(deftest batch-event-test
|
||||||
(is (= :timer-tick ((tui/after 0 :timer-tick))))
|
(testing "batch multiple events"
|
||||||
(is (= [:my-tick {:id 1}] ((tui/after 0 [:my-tick {:id 1}]))))))
|
(let [event (ev/batch {:type :msg1} {:type :msg2})]
|
||||||
|
(is (= :batch (:type event)))
|
||||||
(deftest batch-command-test
|
(is (= 2 (count (:events event))))
|
||||||
(testing "batch two commands"
|
(is (= [{:type :msg1} {:type :msg2}] (:events event)))))
|
||||||
(let [cmd (tui/batch (tui/after 100 :tick1) tui/quit)]
|
|
||||||
(is (= :batch (first cmd)))
|
|
||||||
(is (= 3 (count cmd)))
|
|
||||||
(is (fn? (second cmd)))
|
|
||||||
(is (= [:quit] (last cmd)))))
|
|
||||||
|
|
||||||
(testing "batch three commands"
|
|
||||||
(let [cmd (tui/batch (tui/after 50 :t1) (tui/after 100 :t2) tui/quit)]
|
|
||||||
(is (= :batch (first cmd)))
|
|
||||||
(is (= 4 (count cmd)))))
|
|
||||||
|
|
||||||
(testing "batch filters nil"
|
(testing "batch filters nil"
|
||||||
(let [cmd (tui/batch nil (tui/send-msg :msg1) nil)]
|
(let [event (ev/batch nil {:type :msg1} nil)]
|
||||||
(is (= :batch (first cmd)))
|
(is (= :batch (:type event)))
|
||||||
(is (= 2 (count cmd))))
|
(is (= 1 (count (:events event)))))
|
||||||
(is (= [:batch] (tui/batch nil nil nil))))
|
(is (nil? (ev/batch nil nil nil))))
|
||||||
|
|
||||||
(testing "batch with single command"
|
(testing "batch with single event"
|
||||||
(is (= [:batch [:quit]] (tui/batch tui/quit)))))
|
(let [event (ev/batch {:type :msg1})]
|
||||||
|
(is (= :batch (:type event)))
|
||||||
|
(is (= 1 (count (:events event)))))))
|
||||||
|
|
||||||
(deftest sequentially-command-test
|
(deftest sequential-event-test
|
||||||
(testing "sequentially two commands"
|
(testing "sequential multiple events"
|
||||||
(let [cmd (tui/sequentially (tui/after 100 :tick1) tui/quit)]
|
(let [event (ev/sequential {:type :msg1} {:type :msg2})]
|
||||||
(is (= :seq (first cmd)))
|
(is (= :sequential (:type event)))
|
||||||
(is (= 3 (count cmd)))
|
(is (= 2 (count (:events event))))))
|
||||||
(is (fn? (second cmd)))
|
|
||||||
(is (= [:quit] (last cmd)))))
|
|
||||||
|
|
||||||
(testing "sequentially filters nil"
|
(testing "sequential filters nil"
|
||||||
(let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)]
|
(let [event (ev/sequential nil {:type :msg1} nil)]
|
||||||
(is (= :seq (first cmd)))
|
(is (= :sequential (:type event)))
|
||||||
(is (= 2 (count cmd)))))
|
(is (= 1 (count (:events event))))))
|
||||||
|
|
||||||
(testing "sequentially with functions"
|
(testing "sequential with delay and quit"
|
||||||
(let [f (fn [] :msg)
|
(let [event (ev/sequential
|
||||||
cmd (tui/sequentially f tui/quit)]
|
(ev/delayed-event 100 {:type :tick})
|
||||||
(is (= 3 (count cmd)))
|
(ev/quit))]
|
||||||
(is (= :seq (first cmd)))
|
(is (= :sequential (:type event)))
|
||||||
(is (fn? (second cmd))))))
|
(is (= 2 (count (:events event)))))))
|
||||||
|
|
||||||
(deftest send-msg-command-test
|
(deftest shell-event-test
|
||||||
(testing "from http pattern: send-msg creates function"
|
(testing "shell creates shell event"
|
||||||
(let [cmd (tui/send-msg [:http-success 200])]
|
(let [event (ev/shell ["git" "status"] {:type :git-result})]
|
||||||
(is (fn? cmd))
|
(is (= :shell (:type event)))
|
||||||
(is (= [:http-success 200] (cmd)))))
|
(is (= ["git" "status"] (:cmd event)))
|
||||||
|
(is (= {:type :git-result} (:event event))))))
|
||||||
|
|
||||||
(testing "send-msg with map"
|
(deftest debounce-event-test
|
||||||
(let [cmd (tui/send-msg {:type :custom :data 42})]
|
(testing "debounce creates debounce event"
|
||||||
(is (= {:type :custom :data 42} (cmd)))))
|
(let [event (ev/debounce :search 300 {:type :do-search :query "test"})]
|
||||||
|
(is (= :debounce (:type event)))
|
||||||
(testing "send-msg with keyword"
|
(is (= :search (:id event)))
|
||||||
(let [cmd (tui/send-msg :done)]
|
(is (= 300 (:ms event)))
|
||||||
(is (= :done (cmd))))))
|
(is (= {:type :do-search :query "test"} (:event event))))))
|
||||||
|
|
||||||
(deftest custom-command-function-test
|
|
||||||
(testing "from http: custom async command pattern"
|
|
||||||
(let [fetch-result (atom nil)
|
|
||||||
cmd (fn []
|
|
||||||
(reset! fetch-result :fetched)
|
|
||||||
[:http-success 200])]
|
|
||||||
;; Execute command
|
|
||||||
(is (= [:http-success 200] (cmd)))
|
|
||||||
(is (= :fetched @fetch-result)))))
|
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; UPDATE FUNCTION PATTERNS
|
;; UPDATE FUNCTION PATTERNS
|
||||||
;; Testing the [model cmd] return contract
|
;; Testing the {:model m :events [...]} return contract
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest update-returns-tuple-test
|
(deftest update-returns-map-test
|
||||||
(testing "update always returns [model cmd] tuple"
|
(testing "update always returns {:model m :events [...]}"
|
||||||
(let [model {:count 0}
|
(let [model {:count 0}
|
||||||
;; Counter-style update
|
;; Counter-style update
|
||||||
update-fn (fn [m msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg "q") [m tui/quit]
|
(ev/key= event \q) {:model model :events [(ev/quit)]}
|
||||||
(tui/key= msg :up) [(update m :count inc) nil]
|
(ev/key= event :up) {:model (update model :count inc)}
|
||||||
:else [m nil]))]
|
:else {:model model}))]
|
||||||
|
|
||||||
;; Quit returns original model + quit command
|
;; Quit returns original model + quit event
|
||||||
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
|
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \q}})]
|
||||||
(is (= model new-model))
|
(is (= {:count 0} model))
|
||||||
(is (= tui/quit cmd)))
|
(is (= [{:type :quit}] events)))
|
||||||
|
|
||||||
;; Up returns modified model + nil command
|
;; Up returns modified model, no events
|
||||||
(let [[new-model cmd] (update-fn model [:key :up])]
|
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key :up}})]
|
||||||
(is (= {:count 1} new-model))
|
(is (= {:count 1} model))
|
||||||
(is (nil? cmd)))
|
(is (nil? events)))
|
||||||
|
|
||||||
;; Unknown key returns unchanged model + nil command
|
;; Unknown key returns unchanged model, no events
|
||||||
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
|
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \x}})]
|
||||||
(is (= model new-model))
|
(is (= {:count 0} model))
|
||||||
(is (nil? cmd))))))
|
(is (nil? events))))))
|
||||||
|
|
||||||
(deftest counter-update-pattern-test
|
(deftest counter-update-pattern-test
|
||||||
(testing "counter increment/decrement pattern"
|
(testing "counter increment/decrement pattern"
|
||||||
(let [update-fn (fn [{:keys [count] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :count inc) nil]
|
{:model (update model :count inc)}
|
||||||
|
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :count dec) nil]
|
{:model (update model :count dec)}
|
||||||
|
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :count 0) nil]
|
{:model (assoc model :count 0)}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Test sequence: up, up, down, reset
|
;; Test sequence: up, up, down, reset
|
||||||
(let [m0 {:count 0}
|
(let [m0 {:count 0}
|
||||||
[m1 _] (update-fn m0 [:key :up])
|
m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
|
||||||
[m2 _] (update-fn m1 [:key {:char \k}])
|
m2 (:model (update-fn {:model m1 :event {:type :key :key \k}}))
|
||||||
[m3 _] (update-fn m2 [:key :down])
|
m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
|
||||||
[m4 _] (update-fn m3 [:key {:char \r}])]
|
m4 (:model (update-fn {:model m3 :event {:type :key :key \r}}))]
|
||||||
(is (= 1 (:count m1)))
|
(is (= 1 (:count m1)))
|
||||||
(is (= 2 (:count m2)))
|
(is (= 2 (:count m2)))
|
||||||
(is (= 1 (:count m3)))
|
(is (= 1 (:count m3)))
|
||||||
@@ -242,68 +226,69 @@
|
|||||||
|
|
||||||
(deftest timer-update-pattern-test
|
(deftest timer-update-pattern-test
|
||||||
(testing "timer tick handling pattern"
|
(testing "timer tick handling pattern"
|
||||||
(let [update-fn (fn [{:keys [seconds running] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(= msg :timer-tick)
|
(= (:type event) :timer-tick)
|
||||||
(if running
|
(if (:running model)
|
||||||
(let [new-seconds (dec seconds)]
|
(let [new-seconds (dec (:seconds model))]
|
||||||
(if (<= new-seconds 0)
|
(if (<= new-seconds 0)
|
||||||
[(assoc model :seconds 0 :done true :running false) nil]
|
{:model (assoc model :seconds 0 :done true :running false)}
|
||||||
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)]))
|
{:model (assoc model :seconds new-seconds)
|
||||||
[model nil])
|
:events [(ev/delayed-event 1000 {:type :timer-tick})]}))
|
||||||
|
{:model model})
|
||||||
|
|
||||||
(tui/key= msg " ")
|
(ev/key= event \space)
|
||||||
(let [new-running (not running)]
|
(let [new-running (not (:running model))]
|
||||||
[(assoc model :running new-running)
|
{:model (assoc model :running new-running)
|
||||||
(when new-running (tui/after 1000 :timer-tick))])
|
:events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Test tick countdown
|
;; Test tick countdown
|
||||||
(let [m0 {:seconds 3 :running true :done false}
|
(let [m0 {:seconds 3 :running true :done false}
|
||||||
[m1 c1] (update-fn m0 :timer-tick)
|
r1 (update-fn {:model m0 :event {:type :timer-tick}})
|
||||||
[m2 c2] (update-fn m1 :timer-tick)
|
r2 (update-fn {:model (:model r1) :event {:type :timer-tick}})
|
||||||
[m3 c3] (update-fn m2 :timer-tick)]
|
r3 (update-fn {:model (:model r2) :event {:type :timer-tick}})]
|
||||||
(is (= 2 (:seconds m1)))
|
(is (= 2 (:seconds (:model r1))))
|
||||||
(is (fn? c1))
|
(is (= 1 (count (:events r1))))
|
||||||
(is (= 1 (:seconds m2)))
|
(is (= 1 (:seconds (:model r2))))
|
||||||
(is (fn? c2))
|
(is (= 1 (count (:events r2))))
|
||||||
(is (= 0 (:seconds m3)))
|
(is (= 0 (:seconds (:model r3))))
|
||||||
(is (:done m3))
|
(is (:done (:model r3)))
|
||||||
(is (not (:running m3)))
|
(is (not (:running (:model r3))))
|
||||||
(is (nil? c3)))
|
(is (nil? (:events r3))))
|
||||||
|
|
||||||
;; Test pause/resume
|
;; Test pause/resume
|
||||||
(let [m0 {:seconds 5 :running true :done false}
|
(let [m0 {:seconds 5 :running true :done false}
|
||||||
[m1 c1] (update-fn m0 [:key {:char \space}])
|
r1 (update-fn {:model m0 :event {:type :key :key \space}})
|
||||||
[m2 c2] (update-fn m1 [:key {:char \space}])]
|
r2 (update-fn {:model (:model r1) :event {:type :key :key \space}})]
|
||||||
(is (not (:running m1)))
|
(is (not (:running (:model r1))))
|
||||||
(is (nil? c1))
|
(is (nil? (:events r1)))
|
||||||
(is (:running m2))
|
(is (:running (:model r2)))
|
||||||
(is (fn? c2))))))
|
(is (= 1 (count (:events r2))))))))
|
||||||
|
|
||||||
(deftest list-selection-update-pattern-test
|
(deftest list-selection-update-pattern-test
|
||||||
(testing "cursor navigation with bounds"
|
(testing "cursor navigation with bounds"
|
||||||
(let [items ["a" "b" "c" "d"]
|
(let [items ["a" "b" "c" "d"]
|
||||||
update-fn (fn [{:keys [cursor] :as model} msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :up) (tui/key= msg "k"))
|
(or (ev/key= event :up) (ev/key= event \k))
|
||||||
[(update model :cursor #(max 0 (dec %))) nil]
|
{:model (update model :cursor #(max 0 (dec %)))}
|
||||||
|
|
||||||
(or (tui/key= msg :down) (tui/key= msg "j"))
|
(or (ev/key= event :down) (ev/key= event \j))
|
||||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Test bounds
|
;; Test bounds
|
||||||
(let [m0 {:cursor 0}
|
(let [m0 {:cursor 0}
|
||||||
[m1 _] (update-fn m0 [:key :up]) ; Can't go below 0
|
m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
|
||||||
[m2 _] (update-fn m1 [:key :down])
|
m2 (:model (update-fn {:model m1 :event {:type :key :key :down}}))
|
||||||
[m3 _] (update-fn m2 [:key :down])
|
m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
|
||||||
[m4 _] (update-fn m3 [:key :down])
|
m4 (:model (update-fn {:model m3 :event {:type :key :key :down}}))
|
||||||
[m5 _] (update-fn m4 [:key :down])] ; Can't go above 3
|
m5 (:model (update-fn {:model m4 :event {:type :key :key :down}}))]
|
||||||
(is (= 0 (:cursor m1)))
|
(is (= 0 (:cursor m1)))
|
||||||
(is (= 1 (:cursor m2)))
|
(is (= 1 (:cursor m2)))
|
||||||
(is (= 2 (:cursor m3)))
|
(is (= 2 (:cursor m3)))
|
||||||
@@ -311,108 +296,109 @@
|
|||||||
(is (= 3 (:cursor m5))))))
|
(is (= 3 (:cursor m5))))))
|
||||||
|
|
||||||
(testing "toggle selection pattern"
|
(testing "toggle selection pattern"
|
||||||
(let [update-fn (fn [{:keys [cursor] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg " ")
|
(if (ev/key= event \space)
|
||||||
[(update model :selected
|
{:model (update model :selected
|
||||||
#(if (contains? % cursor)
|
#(let [cursor (:cursor model)]
|
||||||
|
(if (contains? % cursor)
|
||||||
(disj % cursor)
|
(disj % cursor)
|
||||||
(conj % cursor)))
|
(conj % cursor))))}
|
||||||
nil]
|
{:model model}))]
|
||||||
[model nil]))]
|
|
||||||
|
|
||||||
(let [m0 {:cursor 0 :selected #{}}
|
(let [m0 {:cursor 0 :selected #{}}
|
||||||
[m1 _] (update-fn m0 [:key {:char \space}])
|
m1 (:model (update-fn {:model m0 :event {:type :key :key \space}}))
|
||||||
[m2 _] (update-fn (assoc m1 :cursor 2) [:key {:char \space}])
|
m2 (:model (update-fn {:model (assoc m1 :cursor 2) :event {:type :key :key \space}}))
|
||||||
[m3 _] (update-fn (assoc m2 :cursor 0) [:key {:char \space}])]
|
m3 (:model (update-fn {:model (assoc m2 :cursor 0) :event {:type :key :key \space}}))]
|
||||||
(is (= #{0} (:selected m1)))
|
(is (= #{0} (:selected m1)))
|
||||||
(is (= #{0 2} (:selected m2)))
|
(is (= #{0 2} (:selected m2)))
|
||||||
(is (= #{2} (:selected m3)))))))
|
(is (= #{2} (:selected m3)))))))
|
||||||
|
|
||||||
(deftest views-state-machine-pattern-test
|
(deftest views-state-machine-pattern-test
|
||||||
(testing "view state transitions"
|
(testing "view state transitions"
|
||||||
(let [update-fn (fn [{:keys [view] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(case view
|
(case (:view model)
|
||||||
:menu
|
:menu
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg :enter)
|
(ev/key= event :enter)
|
||||||
[(assoc model :view :detail) nil]
|
{:model (assoc model :view :detail)}
|
||||||
(tui/key= msg "q")
|
(ev/key= event \q)
|
||||||
[(assoc model :view :confirm) nil]
|
{:model (assoc model :view :confirm)}
|
||||||
:else [model nil])
|
:else {:model model})
|
||||||
|
|
||||||
:detail
|
:detail
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :escape) (tui/key= msg "b"))
|
(or (ev/key= event :escape) (ev/key= event \b))
|
||||||
[(assoc model :view :menu) nil]
|
{:model (assoc model :view :menu)}
|
||||||
(tui/key= msg "q")
|
(ev/key= event \q)
|
||||||
[(assoc model :view :confirm) nil]
|
{:model (assoc model :view :confirm)}
|
||||||
:else [model nil])
|
:else {:model model})
|
||||||
|
|
||||||
:confirm
|
:confirm
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg "y")
|
(ev/key= event \y)
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
(tui/key= msg "n")
|
(ev/key= event \n)
|
||||||
[(assoc model :view :detail) nil]
|
{:model (assoc model :view :detail)}
|
||||||
:else [model nil])))]
|
:else {:model model})))]
|
||||||
|
|
||||||
;; Menu -> Detail -> Confirm -> Quit
|
;; Menu -> Detail -> Confirm -> Quit
|
||||||
(let [m0 {:view :menu}
|
(let [m0 {:view :menu}
|
||||||
[m1 _] (update-fn m0 [:key :enter])
|
r1 (update-fn {:model m0 :event {:type :key :key :enter}})
|
||||||
[m2 _] (update-fn m1 [:key {:char \q}])
|
r2 (update-fn {:model (:model r1) :event {:type :key :key \q}})
|
||||||
[m3 c3] (update-fn m2 [:key {:char \y}])]
|
r3 (update-fn {:model (:model r2) :event {:type :key :key \y}})]
|
||||||
(is (= :detail (:view m1)))
|
(is (= :detail (:view (:model r1))))
|
||||||
(is (= :confirm (:view m2)))
|
(is (= :confirm (:view (:model r2))))
|
||||||
(is (= tui/quit c3)))
|
(is (= [{:type :quit}] (:events r3))))
|
||||||
|
|
||||||
;; Detail -> Menu (back)
|
;; Detail -> Menu (back)
|
||||||
(let [m0 {:view :detail}
|
(let [m0 {:view :detail}
|
||||||
[m1 _] (update-fn m0 [:key :escape])]
|
r1 (update-fn {:model m0 :event {:type :key :key :escape}})]
|
||||||
(is (= :menu (:view m1)))))))
|
(is (= :menu (:view (:model r1))))))))
|
||||||
|
|
||||||
(deftest http-async-pattern-test
|
(deftest http-async-pattern-test
|
||||||
(testing "HTTP state machine pattern"
|
(testing "HTTP state machine pattern"
|
||||||
(let [update-fn (fn [{:keys [state url] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(and (= state :idle) (tui/key= msg :enter))
|
(and (= (:state model) :idle) (ev/key= event :enter))
|
||||||
[(assoc model :state :loading)
|
{:model (assoc model :state :loading)
|
||||||
(fn [] [:http-success 200])]
|
:events [(ev/shell ["curl" "-s" (:url model)]
|
||||||
|
{:type :http-result})]}
|
||||||
|
|
||||||
(= (first msg) :http-success)
|
(= (:type event) :http-result)
|
||||||
[(assoc model :state :success :status (second msg)) nil]
|
(let [{:keys [success out err]} (:result event)]
|
||||||
|
(if success
|
||||||
|
{:model (assoc model :state :success :status 200)}
|
||||||
|
{:model (assoc model :state :error :error err)}))
|
||||||
|
|
||||||
(= (first msg) :http-error)
|
(ev/key= event \r)
|
||||||
[(assoc model :state :error :error (second msg)) nil]
|
{:model (assoc model :state :idle :status nil :error nil)}
|
||||||
|
|
||||||
(tui/key= msg "r")
|
|
||||||
[(assoc model :state :idle :status nil :error nil) nil]
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Idle -> Loading
|
;; Idle -> Loading
|
||||||
(let [m0 {:state :idle :url "http://test.com"}
|
(let [m0 {:state :idle :url "http://test.com"}
|
||||||
[m1 c1] (update-fn m0 [:key :enter])]
|
r1 (update-fn {:model m0 :event {:type :key :key :enter}})]
|
||||||
(is (= :loading (:state m1)))
|
(is (= :loading (:state (:model r1))))
|
||||||
(is (fn? c1)))
|
(is (= 1 (count (:events r1)))))
|
||||||
|
|
||||||
;; Loading -> Success
|
;; Loading -> Success
|
||||||
(let [m0 {:state :loading}
|
(let [m0 {:state :loading}
|
||||||
[m1 _] (update-fn m0 [:http-success 200])]
|
r1 (update-fn {:model m0 :event {:type :http-result :result {:success true :out "OK"}}})]
|
||||||
(is (= :success (:state m1)))
|
(is (= :success (:state (:model r1))))
|
||||||
(is (= 200 (:status m1))))
|
(is (= 200 (:status (:model r1)))))
|
||||||
|
|
||||||
;; Loading -> Error
|
;; Loading -> Error
|
||||||
(let [m0 {:state :loading}
|
(let [m0 {:state :loading}
|
||||||
[m1 _] (update-fn m0 [:http-error "Connection refused"])]
|
r1 (update-fn {:model m0 :event {:type :http-result :result {:success false :err "Connection refused"}}})]
|
||||||
(is (= :error (:state m1)))
|
(is (= :error (:state (:model r1))))
|
||||||
(is (= "Connection refused" (:error m1))))
|
(is (= "Connection refused" (:error (:model r1)))))
|
||||||
|
|
||||||
;; Reset
|
;; Reset
|
||||||
(let [m0 {:state :error :error "timeout"}
|
(let [m0 {:state :error :error "timeout"}
|
||||||
[m1 _] (update-fn m0 [:key {:char \r}])]
|
r1 (update-fn {:model m0 :event {:type :key :key \r}})]
|
||||||
(is (= :idle (:state m1)))
|
(is (= :idle (:state (:model r1))))
|
||||||
(is (nil? (:error m1)))))))
|
(is (nil? (:error (:model r1))))))))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; RENDER TESTS
|
;; RENDER TESTS
|
||||||
@@ -565,26 +551,3 @@
|
|||||||
(is (str/includes? result "Pizza"))
|
(is (str/includes? result "Pizza"))
|
||||||
(is (str/includes? result "[ ] Sushi"))
|
(is (str/includes? result "[ ] Sushi"))
|
||||||
(is (str/includes? result "[ ] Tacos")))))
|
(is (str/includes? result "[ ] Tacos")))))
|
||||||
|
|
||||||
;; =============================================================================
|
|
||||||
;; KEY-STR TESTS
|
|
||||||
;; =============================================================================
|
|
||||||
|
|
||||||
(deftest key-str-comprehensive-test
|
|
||||||
(testing "character keys"
|
|
||||||
(is (= "q" (tui/key-str [:key {:char \q}])))
|
|
||||||
(is (= " " (tui/key-str [:key {:char \space}]))))
|
|
||||||
|
|
||||||
(testing "special keys"
|
|
||||||
(is (= "enter" (tui/key-str [:key :enter])))
|
|
||||||
(is (= "escape" (tui/key-str [:key :escape])))
|
|
||||||
(is (= "tab" (tui/key-str [:key :tab])))
|
|
||||||
(is (= "backspace" (tui/key-str [:key :backspace])))
|
|
||||||
(is (= "up" (tui/key-str [:key :up])))
|
|
||||||
(is (= "down" (tui/key-str [:key :down])))
|
|
||||||
(is (= "left" (tui/key-str [:key :left])))
|
|
||||||
(is (= "right" (tui/key-str [:key :right]))))
|
|
||||||
|
|
||||||
(testing "modifier keys"
|
|
||||||
(is (= "ctrl+c" (tui/key-str [:key {:ctrl true :char \c}])))
|
|
||||||
(is (= "alt+x" (tui/key-str [:key {:alt true :char \x}])))))
|
|
||||||
|
|||||||
+82
-183
@@ -1,90 +1,88 @@
|
|||||||
(ns tui.core-test
|
(ns tui.core-test
|
||||||
"Integration tests for the TUI engine.
|
"Integration tests for the TUI engine.
|
||||||
Tests the update loop, command handling, and full render pipeline."
|
Tests the update loop, event handling, and full render pipeline."
|
||||||
(:require [clojure.test :refer [deftest testing is]]
|
(:require [clojure.test :refer [deftest testing is]]
|
||||||
[clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]]
|
[clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]]
|
||||||
[tui.core :as tui]
|
[tui.core :as tui]
|
||||||
[tui.events :as ev]
|
[tui.events :as ev]
|
||||||
[tui.render :as render]))
|
[tui.render :as render]))
|
||||||
|
|
||||||
;; === Legacy Command Tests (Backward Compatibility) ===
|
;; =============================================================================
|
||||||
|
;; EVENT CONSTRUCTOR TESTS
|
||||||
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest quit-command-test
|
(deftest quit-event-test
|
||||||
(testing "quit command is correct vector (legacy)"
|
(testing "tui/quit creates quit event"
|
||||||
(is (= [:quit] tui/quit))))
|
(is (= {:type :quit} (tui/quit)))))
|
||||||
|
|
||||||
(deftest after-command-test
|
(deftest delayed-event-test
|
||||||
(testing "after creates a function command (legacy)"
|
(testing "tui/delayed-event creates delayed-event event"
|
||||||
(let [cmd (tui/after 0 :my-tick)]
|
(let [event (tui/delayed-event 1000 {:type :timer-tick})]
|
||||||
(is (fn? cmd))
|
(is (= :delayed-event (:type event)))
|
||||||
(is (= :my-tick (cmd)))))
|
(is (= 1000 (:ms event)))
|
||||||
|
(is (= {:type :timer-tick} (:event event))))))
|
||||||
|
|
||||||
(testing "after can send any message type"
|
(deftest batch-event-test
|
||||||
(is (= [:timer-tick {:id 1}] ((tui/after 0 [:timer-tick {:id 1}]))))
|
(testing "tui/batch creates batch event"
|
||||||
(is (= :simple-msg ((tui/after 0 :simple-msg)))))
|
(let [event (tui/batch {:type :msg1} {:type :msg2})]
|
||||||
|
(is (= :batch (:type event)))
|
||||||
|
(is (= 2 (count (:events event))))))
|
||||||
|
|
||||||
(testing "after with non-zero delay creates function"
|
(testing "batch filters nil"
|
||||||
(is (fn? (tui/after 100 :tick)))
|
(let [event (tui/batch nil {:type :msg1} nil)]
|
||||||
(is (fn? (tui/after 1000 :tick)))))
|
(is (= 1 (count (:events event)))))
|
||||||
|
(is (nil? (tui/batch nil nil nil)))))
|
||||||
|
|
||||||
(deftest batch-command-test
|
(deftest sequential-event-test
|
||||||
(testing "batch combines commands (legacy)"
|
(testing "tui/sequential creates sequential event"
|
||||||
(let [cmd (tui/batch (tui/send-msg :msg1) tui/quit)]
|
(let [event (tui/sequential {:type :msg1} {:type :msg2})]
|
||||||
(is (vector? cmd))
|
(is (= :sequential (:type event)))
|
||||||
(is (= :batch (first cmd)))
|
(is (= 2 (count (:events event))))))
|
||||||
(is (= 3 (count cmd)))
|
|
||||||
(is (= [:quit] (last cmd)))))
|
|
||||||
|
|
||||||
(testing "batch filters nil commands"
|
(testing "sequential filters nil"
|
||||||
(let [cmd (tui/batch nil (tui/send-msg :msg1) nil)]
|
(let [event (tui/sequential nil {:type :msg1} nil)]
|
||||||
(is (= :batch (first cmd)))
|
(is (= 1 (count (:events event)))))))
|
||||||
(is (= 2 (count cmd))))))
|
|
||||||
|
|
||||||
(deftest sequentially-command-test
|
(deftest shell-event-test
|
||||||
(testing "sequentially creates seq command (legacy)"
|
(testing "tui/shell creates shell event"
|
||||||
(let [cmd (tui/sequentially (tui/send-msg :msg1) tui/quit)]
|
(let [event (tui/shell ["git" "status"] {:type :git-result})]
|
||||||
(is (vector? cmd))
|
(is (= :shell (:type event)))
|
||||||
(is (= :seq (first cmd)))
|
(is (= ["git" "status"] (:cmd event)))
|
||||||
(is (= 3 (count cmd)))
|
(is (= {:type :git-result} (:event event))))))
|
||||||
(is (= [:quit] (last cmd)))))
|
|
||||||
|
|
||||||
(testing "sequentially filters nil commands"
|
(deftest debounce-event-test
|
||||||
(let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)]
|
(testing "tui/debounce creates debounce event"
|
||||||
(is (= :seq (first cmd)))
|
(let [event (tui/debounce :search 300 {:type :do-search})]
|
||||||
(is (= 2 (count cmd))))))
|
(is (= :debounce (:type event)))
|
||||||
|
(is (= :search (:id event)))
|
||||||
|
(is (= 300 (:ms event))))))
|
||||||
|
|
||||||
(deftest send-msg-command-test
|
;; =============================================================================
|
||||||
(testing "send-msg creates function that returns message (legacy)"
|
;; KEY MATCHING TESTS
|
||||||
(let [cmd (tui/send-msg {:type :custom :data 42})]
|
;; =============================================================================
|
||||||
(is (fn? cmd))
|
|
||||||
(is (= {:type :custom :data 42} (cmd))))))
|
|
||||||
|
|
||||||
;; === Legacy Key Matching Tests ===
|
(deftest key=-test
|
||||||
|
(testing "key= matches characters"
|
||||||
|
(is (tui/key= {:type :key :key \q} \q))
|
||||||
|
(is (not (tui/key= {:type :key :key \a} \q))))
|
||||||
|
|
||||||
(deftest key=-legacy-test
|
(testing "key= matches special keys"
|
||||||
(testing "key= works with legacy format"
|
|
||||||
(is (tui/key= [:key {:char \q}] "q"))
|
|
||||||
(is (tui/key= [:key :enter] :enter))
|
|
||||||
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))
|
|
||||||
(is (not (tui/key= [:key {:char \a}] "b")))))
|
|
||||||
|
|
||||||
(deftest key=-new-format-test
|
|
||||||
(testing "key= works with new format"
|
|
||||||
(is (tui/key= {:type :key :key \q} "q"))
|
|
||||||
(is (tui/key= {:type :key :key :enter} :enter))
|
(is (tui/key= {:type :key :key :enter} :enter))
|
||||||
(is (tui/key= {:type :key :key \c :modifiers #{:ctrl}} [:ctrl \c]))
|
(is (tui/key= {:type :key :key :up} :up))
|
||||||
(is (not (tui/key= {:type :key :key \a} "b")))))
|
(is (tui/key= {:type :key :key :escape} :escape)))
|
||||||
|
|
||||||
(deftest key-str-test
|
(testing "key= matches modifiers"
|
||||||
(testing "key-str converts key to string (legacy)"
|
(is (tui/key= {:type :key :key \c :modifiers #{:ctrl}} \c #{:ctrl}))
|
||||||
(is (= "q" (tui/key-str [:key {:char \q}])))
|
(is (not (tui/key= {:type :key :key \c :modifiers #{:ctrl}} \c)))
|
||||||
(is (= "enter" (tui/key-str [:key :enter]))))
|
(is (not (tui/key= {:type :key :key \c} \c #{:ctrl}))))
|
||||||
|
|
||||||
(testing "key-str converts key to string (new format)"
|
(testing "key= doesn't match non-key events"
|
||||||
(is (= "q" (tui/key-str {:type :key :key \q})))
|
(is (not (tui/key= {:type :timer-tick} \q)))
|
||||||
(is (= "enter" (tui/key-str {:type :key :key :enter})))))
|
(is (not (tui/key= {:type :quit} :enter)))))
|
||||||
|
|
||||||
;; === Full Pipeline Tests ===
|
;; =============================================================================
|
||||||
|
;; RENDER PIPELINE TESTS
|
||||||
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest render-pipeline-test
|
(deftest render-pipeline-test
|
||||||
(testing "model -> view -> render produces valid output"
|
(testing "model -> view -> render produces valid output"
|
||||||
@@ -98,10 +96,12 @@
|
|||||||
(is (clojure.string/includes? rendered "Counter"))
|
(is (clojure.string/includes? rendered "Counter"))
|
||||||
(is (clojure.string/includes? rendered "Count: 5")))))
|
(is (clojure.string/includes? rendered "Count: 5")))))
|
||||||
|
|
||||||
;; === New API Update Function Tests ===
|
;; =============================================================================
|
||||||
|
;; UPDATE FUNCTION CONTRACT TESTS
|
||||||
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest new-update-function-contract-test
|
(deftest update-function-contract-test
|
||||||
(testing "new update function returns {:model ... :events ...}"
|
(testing "update function returns {:model ... :events ...}"
|
||||||
(let [update-fn (fn [{:keys [model event]}]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(ev/key= event \q) {:model model :events [(ev/quit)]}
|
(ev/key= event \q) {:model model :events [(ev/quit)]}
|
||||||
@@ -124,33 +124,9 @@
|
|||||||
(is (= {:n 0} model))
|
(is (= {:n 0} model))
|
||||||
(is (nil? events))))))
|
(is (nil? events))))))
|
||||||
|
|
||||||
;; === Legacy Update Function Tests ===
|
;; =============================================================================
|
||||||
|
;; EVENT EXECUTION TESTS
|
||||||
(deftest legacy-update-function-contract-test
|
;; =============================================================================
|
||||||
(testing "legacy update function returns [model cmd] tuple"
|
|
||||||
(let [update-fn (fn [model msg]
|
|
||||||
(cond
|
|
||||||
(tui/key= msg "q") [model tui/quit]
|
|
||||||
(tui/key= msg :up) [(update model :n inc) nil]
|
|
||||||
:else [model nil]))
|
|
||||||
model {:n 0}]
|
|
||||||
|
|
||||||
;; Test quit returns command
|
|
||||||
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
|
|
||||||
(is (= model new-model))
|
|
||||||
(is (= [:quit] cmd)))
|
|
||||||
|
|
||||||
;; Test up returns updated model
|
|
||||||
(let [[new-model cmd] (update-fn model [:key :up])]
|
|
||||||
(is (= {:n 1} new-model))
|
|
||||||
(is (nil? cmd)))
|
|
||||||
|
|
||||||
;; Test unknown key returns model unchanged
|
|
||||||
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
|
|
||||||
(is (= model new-model))
|
|
||||||
(is (nil? cmd))))))
|
|
||||||
|
|
||||||
;; === Event Execution Tests ===
|
|
||||||
|
|
||||||
(deftest execute-quit-event-test
|
(deftest execute-quit-event-test
|
||||||
(testing "quit event puts {:type :quit} on channel"
|
(testing "quit event puts {:type :quit} on channel"
|
||||||
@@ -162,10 +138,10 @@
|
|||||||
(is (= {:type :quit} result)))
|
(is (= {:type :quit} result)))
|
||||||
(close! msg-chan))))
|
(close! msg-chan))))
|
||||||
|
|
||||||
(deftest execute-delay-event-test
|
(deftest execute-delayed-event-test
|
||||||
(testing "delay event sends message after delay"
|
(testing "delayed-event sends message after delay"
|
||||||
(let [msg-chan (chan 1)
|
(let [msg-chan (chan 1)
|
||||||
event (ev/delay 50 {:type :delayed-msg})]
|
event (ev/delayed-event 50 {:type :delayed-msg})]
|
||||||
(#'tui/execute-event! event msg-chan)
|
(#'tui/execute-event! event msg-chan)
|
||||||
;; Should not receive immediately
|
;; Should not receive immediately
|
||||||
(let [immediate (alt!!
|
(let [immediate (alt!!
|
||||||
@@ -218,94 +194,17 @@
|
|||||||
(is (= :timeout result)))
|
(is (= :timeout result)))
|
||||||
(close! msg-chan))))
|
(close! msg-chan))))
|
||||||
|
|
||||||
;; === Legacy Command Execution Tests ===
|
;; =============================================================================
|
||||||
|
;; DEFAPP MACRO TESTS
|
||||||
(deftest execute-quit-command-legacy-test
|
;; =============================================================================
|
||||||
(testing "quit command puts {:type :quit} on channel"
|
|
||||||
(let [msg-chan (chan 1)]
|
|
||||||
(#'tui/execute-cmd! [:quit] msg-chan)
|
|
||||||
(let [result (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 100) :timeout)]
|
|
||||||
(is (= {:type :quit} result)))
|
|
||||||
(close! msg-chan))))
|
|
||||||
|
|
||||||
(deftest execute-after-command-legacy-test
|
|
||||||
(testing "after command sends message after delay"
|
|
||||||
(let [msg-chan (chan 1)
|
|
||||||
cmd (tui/after 50 :delayed-msg)]
|
|
||||||
(#'tui/execute-cmd! cmd msg-chan)
|
|
||||||
;; Should not receive immediately
|
|
||||||
(let [immediate (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 10) :timeout)]
|
|
||||||
(is (= :timeout immediate)))
|
|
||||||
;; Should receive after delay
|
|
||||||
(let [delayed (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 200) :timeout)]
|
|
||||||
(is (= :delayed-msg delayed)))
|
|
||||||
(close! msg-chan))))
|
|
||||||
|
|
||||||
(deftest execute-function-command-legacy-test
|
|
||||||
(testing "function command executes and sends result"
|
|
||||||
(let [msg-chan (chan 1)
|
|
||||||
cmd (fn [] {:custom :message})]
|
|
||||||
(#'tui/execute-cmd! cmd msg-chan)
|
|
||||||
(let [result (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 100) :timeout)]
|
|
||||||
(is (= {:custom :message} result)))
|
|
||||||
(close! msg-chan))))
|
|
||||||
|
|
||||||
(deftest execute-batch-command-legacy-test
|
|
||||||
(testing "batch executes multiple commands"
|
|
||||||
(let [msg-chan (chan 10)]
|
|
||||||
(#'tui/execute-cmd! [:batch
|
|
||||||
(fn [] :msg1)
|
|
||||||
(fn [] :msg2)]
|
|
||||||
msg-chan)
|
|
||||||
;; Give time for async execution
|
|
||||||
(Thread/sleep 50)
|
|
||||||
(let [results (loop [msgs []]
|
|
||||||
(let [msg (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 10) nil)]
|
|
||||||
(if msg
|
|
||||||
(recur (conj msgs msg))
|
|
||||||
msgs)))]
|
|
||||||
(is (= #{:msg1 :msg2} (set results))))
|
|
||||||
(close! msg-chan))))
|
|
||||||
|
|
||||||
(deftest execute-nil-command-legacy-test
|
|
||||||
(testing "nil command does nothing"
|
|
||||||
(let [msg-chan (chan 1)]
|
|
||||||
(#'tui/execute-cmd! nil msg-chan)
|
|
||||||
(let [result (alt!!
|
|
||||||
msg-chan ([v] v)
|
|
||||||
(timeout 50) :timeout)]
|
|
||||||
(is (= :timeout result)))
|
|
||||||
(close! msg-chan))))
|
|
||||||
|
|
||||||
;; === Defapp Macro Tests ===
|
|
||||||
|
|
||||||
(deftest defapp-macro-test
|
(deftest defapp-macro-test
|
||||||
(testing "defapp creates app map (legacy)"
|
(testing "defapp creates app map"
|
||||||
(tui/defapp test-app-legacy
|
(tui/defapp test-app
|
||||||
:init {:count 0}
|
|
||||||
:update (fn [m msg] [m nil])
|
|
||||||
:view (fn [m] [:text "test"]))
|
|
||||||
(is (map? test-app-legacy))
|
|
||||||
(is (= {:count 0} (:init test-app-legacy)))
|
|
||||||
(is (fn? (:update test-app-legacy)))
|
|
||||||
(is (fn? (:view test-app-legacy))))
|
|
||||||
|
|
||||||
(testing "defapp creates app map (new)"
|
|
||||||
(tui/defapp test-app-new
|
|
||||||
:init {:count 0}
|
:init {:count 0}
|
||||||
:update (fn [{:keys [model event]}] {:model model})
|
:update (fn [{:keys [model event]}] {:model model})
|
||||||
:view (fn [m size] [:text "test"]))
|
:view (fn [m size] [:text "test"]))
|
||||||
(is (map? test-app-new))
|
(is (map? test-app))
|
||||||
(is (= {:count 0} (:init test-app-new)))
|
(is (= {:count 0} (:init test-app)))
|
||||||
(is (fn? (:update test-app-new)))
|
(is (fn? (:update test-app)))
|
||||||
(is (fn? (:view test-app-new)))))
|
(is (fn? (:view test-app)))))
|
||||||
|
|||||||
+68
-104
@@ -4,6 +4,7 @@
|
|||||||
(:require [clojure.test :refer [deftest testing is are]]
|
(:require [clojure.test :refer [deftest testing is are]]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[tui.core :as tui]
|
[tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
[tui.render :as render]
|
[tui.render :as render]
|
||||||
[tui.input :as input]
|
[tui.input :as input]
|
||||||
[tui.ansi :as ansi]))
|
[tui.ansi :as ansi]))
|
||||||
@@ -145,98 +146,65 @@
|
|||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest key-match-edge-cases-test
|
(deftest key-match-edge-cases-test
|
||||||
(testing "empty string pattern"
|
(testing "nil event returns false"
|
||||||
(is (not (input/key-match? [:key {:char \a}] ""))))
|
(is (not (ev/key= nil \q)))
|
||||||
|
(is (not (ev/key= nil :enter))))
|
||||||
|
|
||||||
(testing "multi-char string pattern only matches first char"
|
(testing "non-key event returns false"
|
||||||
;; The current implementation only looks at first char
|
(is (not (ev/key= {:type :timer-tick} \q)))
|
||||||
(is (input/key-match? [:key {:char \q}] "quit")))
|
(is (not (ev/key= {:type :http-result :status 200} :enter))))
|
||||||
|
|
||||||
(testing "nil message returns false"
|
(testing "key event with missing key field"
|
||||||
(is (not (input/key-match? nil "q")))
|
(is (not (ev/key= {:type :key} \q)))
|
||||||
(is (not (input/key-match? nil :enter))))
|
(is (not (ev/key= {:type :key :modifiers #{:ctrl}} \c #{:ctrl})))))
|
||||||
|
|
||||||
(testing "non-key message returns false"
|
|
||||||
(is (not (input/key-match? [:tick 123] "q")))
|
|
||||||
(is (not (input/key-match? [:http-success 200] :enter)))
|
|
||||||
(is (not (input/key-match? "not a vector" "q"))))
|
|
||||||
|
|
||||||
(testing "unknown key message structure"
|
|
||||||
(is (not (input/key-match? [:key {:unknown true}] "q")))
|
|
||||||
(is (not (input/key-match? [:key {}] "q")))))
|
|
||||||
|
|
||||||
(deftest key-str-edge-cases-test
|
|
||||||
(testing "nil message returns empty string"
|
|
||||||
(is (= "" (input/key->str nil))))
|
|
||||||
|
|
||||||
(testing "non-key message returns string representation"
|
|
||||||
;; Legacy format returns the second element as string
|
|
||||||
(is (string? (input/key->str [:tick 123])))
|
|
||||||
(is (string? (input/key->str [:custom :message]))))
|
|
||||||
|
|
||||||
(testing "key message with empty map"
|
|
||||||
(is (= "" (input/key->str [:key {}]))))
|
|
||||||
|
|
||||||
(testing "ctrl and alt combined"
|
|
||||||
;; This is an edge case - both modifiers
|
|
||||||
(is (= "ctrl+alt+x" (input/key->str [:key {:ctrl true :alt true :char \x}])))))
|
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; COMMAND EDGE CASES
|
;; EVENT EDGE CASES
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest batch-edge-cases-test
|
(deftest batch-edge-cases-test
|
||||||
(testing "batch with all nils"
|
(testing "batch with all nils"
|
||||||
(is (= [:batch] (tui/batch nil nil nil))))
|
(is (nil? (ev/batch nil nil nil))))
|
||||||
|
|
||||||
(testing "batch with single command"
|
(testing "batch with single event"
|
||||||
(is (= [:batch tui/quit] (tui/batch tui/quit))))
|
(let [event (ev/batch {:type :msg1})]
|
||||||
|
(is (= :batch (:type event)))
|
||||||
|
(is (= 1 (count (:events event))))))
|
||||||
|
|
||||||
(testing "batch with no arguments"
|
(testing "batch with no arguments"
|
||||||
(is (= [:batch] (tui/batch))))
|
(is (nil? (ev/batch))))
|
||||||
|
|
||||||
(testing "batch with many commands"
|
(testing "batch with many events"
|
||||||
(let [cmd (tui/batch (tui/after 1 :t1) (tui/after 2 :t2) (tui/after 3 :t3) (tui/after 4 :t4) (tui/after 5 :t5))]
|
(let [event (ev/batch {:type :t1} {:type :t2} {:type :t3} {:type :t4} {:type :t5})]
|
||||||
(is (= 6 (count cmd))) ; :batch + 5 commands
|
(is (= 5 (count (:events event))))
|
||||||
(is (= :batch (first cmd))))))
|
(is (= :batch (:type event))))))
|
||||||
|
|
||||||
(deftest sequentially-edge-cases-test
|
(deftest sequential-edge-cases-test
|
||||||
(testing "sequentially with all nils"
|
(testing "sequential with all nils"
|
||||||
(is (= [:seq] (tui/sequentially nil nil nil))))
|
(is (nil? (ev/sequential nil nil nil))))
|
||||||
|
|
||||||
(testing "sequentially with single command"
|
(testing "sequential with single event"
|
||||||
(is (= [:seq tui/quit] (tui/sequentially tui/quit))))
|
(let [event (ev/sequential {:type :msg1})]
|
||||||
|
(is (= :sequential (:type event)))
|
||||||
|
(is (= 1 (count (:events event))))))
|
||||||
|
|
||||||
(testing "sequentially with no arguments"
|
(testing "sequential with no arguments"
|
||||||
(is (= [:seq] (tui/sequentially)))))
|
(is (nil? (ev/sequential)))))
|
||||||
|
|
||||||
(deftest after-edge-cases-test
|
(deftest delayed-event-edge-cases-test
|
||||||
(testing "after with zero delay"
|
(testing "delayed-event with zero delay"
|
||||||
(let [cmd (tui/after 0 :immediate)]
|
(let [event (ev/delayed-event 0 {:type :immediate})]
|
||||||
(is (fn? cmd))
|
(is (= :delayed-event (:type event)))
|
||||||
;; Zero delay executes immediately
|
(is (= 0 (:ms event)))))
|
||||||
(is (= :immediate (cmd)))))
|
|
||||||
|
|
||||||
(testing "after with various delays creates function"
|
(testing "delayed-event with various delays"
|
||||||
;; Don't invoke - just verify the function is created correctly
|
(is (= 1 (:ms (ev/delayed-event 1 {:type :t1}))))
|
||||||
(is (fn? (tui/after 1 :t1)))
|
(is (= 1000 (:ms (ev/delayed-event 1000 {:type :t2}))))
|
||||||
(is (fn? (tui/after 1000 :t2)))
|
(is (= 999999999 (:ms (ev/delayed-event 999999999 {:type :t3})))))
|
||||||
(is (fn? (tui/after 999999999 :t3))))
|
|
||||||
|
|
||||||
(testing "after with complex message"
|
(testing "delayed-event with complex message"
|
||||||
(let [cmd (tui/after 0 [:tick {:id 1 :data [1 2 3]}])]
|
(let [event (ev/delayed-event 0 {:type :tick :id 1 :data [1 2 3]})]
|
||||||
(is (= [:tick {:id 1 :data [1 2 3]}] (cmd))))))
|
(is (= {:type :tick :id 1 :data [1 2 3]} (:event event))))))
|
||||||
|
|
||||||
(deftest send-msg-edge-cases-test
|
|
||||||
(testing "send-msg with nil"
|
|
||||||
(let [cmd (tui/send-msg nil)]
|
|
||||||
(is (fn? cmd))
|
|
||||||
(is (nil? (cmd)))))
|
|
||||||
|
|
||||||
(testing "send-msg with complex message"
|
|
||||||
(let [msg {:type :complex :data [1 2 3] :nested {:a :b}}
|
|
||||||
cmd (tui/send-msg msg)]
|
|
||||||
(is (= msg (cmd))))))
|
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; ANSI EDGE CASES
|
;; ANSI EDGE CASES
|
||||||
@@ -319,27 +287,24 @@
|
|||||||
;; UPDATE FUNCTION EDGE CASES
|
;; UPDATE FUNCTION EDGE CASES
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
|
|
||||||
(deftest update-with-unknown-messages-test
|
(deftest update-with-unknown-events-test
|
||||||
(testing "update function handles unknown messages gracefully"
|
(testing "update function handles unknown events gracefully"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg "q") [model tui/quit]
|
(ev/key= event \q) {:model model :events [(ev/quit)]}
|
||||||
:else [model nil]))]
|
:else {:model model}))]
|
||||||
|
|
||||||
;; Unknown key
|
;; Unknown key
|
||||||
(let [[m cmd] (update-fn {:n 0} [:key {:char \x}])]
|
(let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :key :key \x}})]
|
||||||
(is (= {:n 0} m))
|
(is (= {:n 0} model)))
|
||||||
(is (nil? cmd)))
|
|
||||||
|
|
||||||
;; Unknown message type
|
;; Unknown event type
|
||||||
(let [[m cmd] (update-fn {:n 0} [:unknown :message])]
|
(let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :unknown :message "test"}})]
|
||||||
(is (= {:n 0} m))
|
(is (= {:n 0} model)))
|
||||||
(is (nil? cmd)))
|
|
||||||
|
|
||||||
;; Empty message
|
;; Event with no type
|
||||||
(let [[m cmd] (update-fn {:n 0} [])]
|
(let [{:keys [model]} (update-fn {:model {:n 0} :event {}})]
|
||||||
(is (= {:n 0} m))
|
(is (= {:n 0} model))))))
|
||||||
(is (nil? cmd))))))
|
|
||||||
|
|
||||||
(deftest model-with-complex-state-test
|
(deftest model-with-complex-state-test
|
||||||
(testing "model with nested data structures"
|
(testing "model with nested data structures"
|
||||||
@@ -348,23 +313,22 @@
|
|||||||
:nested {:deep {:value 42}}
|
:nested {:deep {:value 42}}
|
||||||
:selected #{}
|
:selected #{}
|
||||||
:history []}
|
:history []}
|
||||||
update-fn (fn [model msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg :up)
|
(if (ev/key= event :up)
|
||||||
[(-> model
|
{:model (-> model
|
||||||
(update :count inc)
|
(update :count inc)
|
||||||
(update :history conj (:count model)))
|
(update :history conj (:count model)))}
|
||||||
nil]
|
{:model model}))]
|
||||||
[model nil]))]
|
|
||||||
|
|
||||||
(let [[m1 _] (update-fn complex-model [:key :up])
|
(let [r1 (update-fn {:model complex-model :event {:type :key :key :up}})
|
||||||
[m2 _] (update-fn m1 [:key :up])]
|
r2 (update-fn {:model (:model r1) :event {:type :key :key :up}})]
|
||||||
(is (= 1 (:count m1)))
|
(is (= 1 (:count (:model r1))))
|
||||||
(is (= [0] (:history m1)))
|
(is (= [0] (:history (:model r1))))
|
||||||
(is (= 2 (:count m2)))
|
(is (= 2 (:count (:model r2))))
|
||||||
(is (= [0 1] (:history m2)))
|
(is (= [0 1] (:history (:model r2))))
|
||||||
;; Other fields unchanged
|
;; Other fields unchanged
|
||||||
(is (= ["a" "b" "c"] (:items m2)))
|
(is (= ["a" "b" "c"] (:items (:model r2))))
|
||||||
(is (= 42 (get-in m2 [:nested :deep :value])))))))
|
(is (= 42 (get-in (:model r2) [:nested :deep :value])))))))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; VIEW FUNCTION EDGE CASES
|
;; VIEW FUNCTION EDGE CASES
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
(testing "quit returns quit event"
|
(testing "quit returns quit event"
|
||||||
(is (= {:type :quit} (ev/quit)))))
|
(is (= {:type :quit} (ev/quit)))))
|
||||||
|
|
||||||
(deftest delay-test
|
(deftest delayed-event-test
|
||||||
(testing "delay creates delay event"
|
(testing "delayed-event creates delayed-event event"
|
||||||
(is (= {:type :delay :ms 1000 :event {:type :tick}}
|
(is (= {:type :delayed-event :ms 1000 :event {:type :tick}}
|
||||||
(ev/delay 1000 {:type :tick}))))
|
(ev/delayed-event 1000 {:type :tick}))))
|
||||||
|
|
||||||
(testing "delay with different ms values"
|
(testing "delayed-event with different ms values"
|
||||||
(is (= 0 (:ms (ev/delay 0 {:type :x}))))
|
(is (= 0 (:ms (ev/delayed-event 0 {:type :x}))))
|
||||||
(is (= 5000 (:ms (ev/delay 5000 {:type :x}))))))
|
(is (= 5000 (:ms (ev/delayed-event 5000 {:type :x}))))))
|
||||||
|
|
||||||
(deftest shell-test
|
(deftest shell-test
|
||||||
(testing "shell creates shell event with vector cmd"
|
(testing "shell creates shell event with vector cmd"
|
||||||
@@ -124,11 +124,11 @@
|
|||||||
(testing "can compose multiple event constructors"
|
(testing "can compose multiple event constructors"
|
||||||
(let [result (ev/batch
|
(let [result (ev/batch
|
||||||
(ev/shell ["git" "status"] {:type :status})
|
(ev/shell ["git" "status"] {:type :status})
|
||||||
(ev/delay 1000 {:type :refresh}))]
|
(ev/delayed-event 1000 {:type :refresh}))]
|
||||||
(is (= :batch (:type result)))
|
(is (= :batch (:type result)))
|
||||||
(is (= 2 (count (:events result))))
|
(is (= 2 (count (:events result))))
|
||||||
(is (= :shell (:type (first (:events result)))))
|
(is (= :shell (:type (first (:events result)))))
|
||||||
(is (= :delay (:type (second (:events result)))))))
|
(is (= :delayed-event (:type (second (:events result)))))))
|
||||||
|
|
||||||
(testing "can nest batch in sequential"
|
(testing "can nest batch in sequential"
|
||||||
(let [result (ev/sequential
|
(let [result (ev/sequential
|
||||||
|
|||||||
+246
-232
@@ -4,6 +4,7 @@
|
|||||||
(:require [clojure.test :refer [deftest testing is are]]
|
(:require [clojure.test :refer [deftest testing is are]]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[tui.core :as tui]
|
[tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
[tui.render :as render]))
|
[tui.render :as render]))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
@@ -18,43 +19,43 @@
|
|||||||
|
|
||||||
(deftest counter-update-all-keys-test
|
(deftest counter-update-all-keys-test
|
||||||
(testing "counter responds to all documented keys"
|
(testing "counter responds to all documented keys"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg "q")
|
(or (ev/key= event \q)
|
||||||
(tui/key= msg [:ctrl \c]))
|
(ev/key= event \c #{:ctrl}))
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
|
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :count inc) nil]
|
{:model (update model :count inc)}
|
||||||
|
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :count dec) nil]
|
{:model (update model :count dec)}
|
||||||
|
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :count 0) nil]
|
{:model (assoc model :count 0)}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; All increment keys
|
;; All increment keys
|
||||||
(are [msg] (= 1 (:count (first (update-fn {:count 0} msg))))
|
(are [event] (= 1 (:count (:model (update-fn {:model {:count 0} :event event}))))
|
||||||
[:key :up]
|
{:type :key :key :up}
|
||||||
[:key {:char \k}])
|
{:type :key :key \k})
|
||||||
|
|
||||||
;; All decrement keys
|
;; All decrement keys
|
||||||
(are [msg] (= -1 (:count (first (update-fn {:count 0} msg))))
|
(are [event] (= -1 (:count (:model (update-fn {:model {:count 0} :event event}))))
|
||||||
[:key :down]
|
{:type :key :key :down}
|
||||||
[:key {:char \j}])
|
{:type :key :key \j})
|
||||||
|
|
||||||
;; All quit keys
|
;; All quit keys
|
||||||
(are [msg] (= tui/quit (second (update-fn {:count 0} msg)))
|
(are [event] (= [(ev/quit)] (:events (update-fn {:model {:count 0} :event event})))
|
||||||
[:key {:char \q}]
|
{:type :key :key \q}
|
||||||
[:key {:ctrl true :char \c}])
|
{:type :key :key \c :modifiers #{:ctrl}})
|
||||||
|
|
||||||
;; Reset key
|
;; Reset key
|
||||||
(is (= 0 (:count (first (update-fn {:count 42} [:key {:char \r}]))))))))
|
(is (= 0 (:count (:model (update-fn {:model {:count 42} :event {:type :key :key \r}}))))))))
|
||||||
|
|
||||||
(deftest counter-view-color-logic-test
|
(deftest counter-view-color-logic-test
|
||||||
(testing "counter view shows correct colors based on count"
|
(testing "counter view shows correct colors based on count"
|
||||||
@@ -125,66 +126,72 @@
|
|||||||
|
|
||||||
(deftest timer-tick-countdown-test
|
(deftest timer-tick-countdown-test
|
||||||
(testing "timer tick decrements and reaches zero"
|
(testing "timer tick decrements and reaches zero"
|
||||||
(let [update-fn (fn [{:keys [seconds running] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(if (= (:type event) :timer-tick)
|
||||||
(= msg :timer-tick)
|
(if (:running model)
|
||||||
(if running
|
(let [new-seconds (dec (:seconds model))]
|
||||||
(let [new-seconds (dec seconds)]
|
|
||||||
(if (<= new-seconds 0)
|
(if (<= new-seconds 0)
|
||||||
[(assoc model :seconds 0 :done true :running false) nil]
|
{:model (assoc model :seconds 0 :done true :running false)}
|
||||||
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)]))
|
{:model (assoc model :seconds new-seconds)
|
||||||
[model nil])
|
:events [(ev/delayed-event 1000 {:type :timer-tick})]}))
|
||||||
:else [model nil]))]
|
{:model model})
|
||||||
|
{:model model}))]
|
||||||
|
|
||||||
;; Normal tick
|
;; Normal tick
|
||||||
(let [[m1 c1] (update-fn {:seconds 10 :running true :done false} :timer-tick)]
|
(let [result (update-fn {:model {:seconds 10 :running true :done false}
|
||||||
(is (= 9 (:seconds m1)))
|
:event {:type :timer-tick}})]
|
||||||
(is (fn? c1)))
|
(is (= 9 (:seconds (:model result))))
|
||||||
|
(is (= 1 (count (:events result)))))
|
||||||
|
|
||||||
;; Tick to zero
|
;; Tick to zero
|
||||||
(let [[m1 c1] (update-fn {:seconds 1 :running true :done false} :timer-tick)]
|
(let [result (update-fn {:model {:seconds 1 :running true :done false}
|
||||||
(is (= 0 (:seconds m1)))
|
:event {:type :timer-tick}})]
|
||||||
(is (true? (:done m1)))
|
(is (= 0 (:seconds (:model result))))
|
||||||
(is (false? (:running m1)))
|
(is (true? (:done (:model result))))
|
||||||
(is (nil? c1)))
|
(is (false? (:running (:model result))))
|
||||||
|
(is (nil? (:events result))))
|
||||||
|
|
||||||
;; Tick when paused does nothing
|
;; Tick when paused does nothing
|
||||||
(let [[m1 c1] (update-fn {:seconds 5 :running false :done false} :timer-tick)]
|
(let [result (update-fn {:model {:seconds 5 :running false :done false}
|
||||||
(is (= 5 (:seconds m1)))
|
:event {:type :timer-tick}})]
|
||||||
(is (nil? c1))))))
|
(is (= 5 (:seconds (:model result))))
|
||||||
|
(is (nil? (:events result)))))))
|
||||||
|
|
||||||
(deftest timer-pause-resume-test
|
(deftest timer-pause-resume-test
|
||||||
(testing "timer pause/resume with space key"
|
(testing "timer pause/resume with space key"
|
||||||
(let [update-fn (fn [{:keys [running] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg " ")
|
(if (ev/key= event \space)
|
||||||
(let [new-running (not running)]
|
(let [new-running (not (:running model))]
|
||||||
[(assoc model :running new-running)
|
{:model (assoc model :running new-running)
|
||||||
(when new-running (tui/after 1000 :timer-tick))])
|
:events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Pause (running -> not running)
|
;; Pause (running -> not running)
|
||||||
(let [[m1 c1] (update-fn {:seconds 5 :running true} [:key {:char \space}])]
|
(let [result (update-fn {:model {:seconds 5 :running true}
|
||||||
(is (false? (:running m1)))
|
:event {:type :key :key \space}})]
|
||||||
(is (nil? c1)))
|
(is (false? (:running (:model result))))
|
||||||
|
(is (nil? (:events result))))
|
||||||
|
|
||||||
;; Resume (not running -> running)
|
;; Resume (not running -> running)
|
||||||
(let [[m1 c1] (update-fn {:seconds 5 :running false} [:key {:char \space}])]
|
(let [result (update-fn {:model {:seconds 5 :running false}
|
||||||
(is (true? (:running m1)))
|
:event {:type :key :key \space}})]
|
||||||
(is (fn? c1))))))
|
(is (true? (:running (:model result))))
|
||||||
|
(is (= 1 (count (:events result))))))))
|
||||||
|
|
||||||
(deftest timer-reset-test
|
(deftest timer-reset-test
|
||||||
(testing "timer reset restores initial state"
|
(testing "timer reset restores initial state"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg "r")
|
(if (ev/key= event \r)
|
||||||
[(assoc model :seconds 10 :done false :running true)
|
{:model (assoc model :seconds 10 :done false :running true)
|
||||||
(tui/after 1000 :timer-tick)]
|
:events [(ev/delayed-event 1000 {:type :timer-tick})]}
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
(let [[m1 c1] (update-fn {:seconds 0 :done true :running false} [:key {:char \r}])]
|
(let [result (update-fn {:model {:seconds 0 :done true :running false}
|
||||||
(is (= 10 (:seconds m1)))
|
:event {:type :key :key \r}})]
|
||||||
(is (false? (:done m1)))
|
(is (= 10 (:seconds (:model result))))
|
||||||
(is (true? (:running m1)))
|
(is (false? (:done (:model result))))
|
||||||
(is (fn? c1))))))
|
(is (true? (:running (:model result))))
|
||||||
|
(is (= 1 (count (:events result))))))))
|
||||||
|
|
||||||
(deftest timer-view-color-logic-test
|
(deftest timer-view-color-logic-test
|
||||||
(testing "timer view shows correct colors"
|
(testing "timer view shows correct colors"
|
||||||
@@ -239,68 +246,74 @@
|
|||||||
|
|
||||||
(deftest spinner-tick-advances-frame-test
|
(deftest spinner-tick-advances-frame-test
|
||||||
(testing "spinner tick advances frame when loading"
|
(testing "spinner tick advances frame when loading"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (= msg :spinner-frame)
|
(if (= (:type event) :spinner-frame)
|
||||||
(if (:loading model)
|
(if (:loading model)
|
||||||
[(update model :frame inc) (tui/after 80 :spinner-frame)]
|
{:model (update model :frame inc)
|
||||||
[model nil])
|
:events [(ev/delayed-event 80 {:type :spinner-frame})]}
|
||||||
[model nil]))]
|
{:model model})
|
||||||
|
{:model model}))]
|
||||||
|
|
||||||
;; Tick advances frame when loading
|
;; Tick advances frame when loading
|
||||||
(let [[m1 c1] (update-fn {:frame 0 :loading true} :spinner-frame)]
|
(let [result (update-fn {:model {:frame 0 :loading true}
|
||||||
(is (= 1 (:frame m1)))
|
:event {:type :spinner-frame}})]
|
||||||
(is (fn? c1)))
|
(is (= 1 (:frame (:model result))))
|
||||||
|
(is (= 1 (count (:events result)))))
|
||||||
|
|
||||||
;; Tick does nothing when not loading
|
;; Tick does nothing when not loading
|
||||||
(let [[m1 c1] (update-fn {:frame 5 :loading false} :spinner-frame)]
|
(let [result (update-fn {:model {:frame 5 :loading false}
|
||||||
(is (= 5 (:frame m1)))
|
:event {:type :spinner-frame}})]
|
||||||
(is (nil? c1))))))
|
(is (= 5 (:frame (:model result))))
|
||||||
|
(is (nil? (:events result)))))))
|
||||||
|
|
||||||
(deftest spinner-style-switching-test
|
(deftest spinner-style-switching-test
|
||||||
(testing "spinner tab key cycles through styles"
|
(testing "spinner tab key cycles through styles"
|
||||||
(let [styles (keys spinner-styles)
|
(let [styles (vec (keys spinner-styles))
|
||||||
update-fn (fn [{:keys [style-idx] :as model} msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg :tab)
|
(if (ev/key= event :tab)
|
||||||
(let [new-idx (mod (inc style-idx) (count styles))]
|
(let [new-idx (mod (inc (:style-idx model)) (count styles))]
|
||||||
[(assoc model
|
{:model (assoc model
|
||||||
:style-idx new-idx
|
:style-idx new-idx
|
||||||
:style (nth styles new-idx))
|
:style (nth styles new-idx))})
|
||||||
nil])
|
{:model model}))]
|
||||||
[model nil]))]
|
|
||||||
|
|
||||||
;; Tab advances style
|
;; Tab advances style
|
||||||
(let [[m1 _] (update-fn {:style-idx 0 :style (first styles)} [:key :tab])]
|
(let [result (update-fn {:model {:style-idx 0 :style (first styles)}
|
||||||
(is (= 1 (:style-idx m1))))
|
:event {:type :key :key :tab}})]
|
||||||
|
(is (= 1 (:style-idx (:model result)))))
|
||||||
|
|
||||||
;; Tab wraps around
|
;; Tab wraps around
|
||||||
(let [last-idx (dec (count styles))
|
(let [last-idx (dec (count styles))
|
||||||
[m1 _] (update-fn {:style-idx last-idx :style (last styles)} [:key :tab])]
|
result (update-fn {:model {:style-idx last-idx :style (last styles)}
|
||||||
(is (= 0 (:style-idx m1)))))))
|
:event {:type :key :key :tab}})]
|
||||||
|
(is (= 0 (:style-idx (:model result))))))))
|
||||||
|
|
||||||
(deftest spinner-completion-test
|
(deftest spinner-completion-test
|
||||||
(testing "spinner space key completes loading"
|
(testing "spinner space key completes loading"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg " ")
|
(if (ev/key= event \space)
|
||||||
[(assoc model :loading false :message "Done!") nil]
|
{:model (assoc model :loading false :message "Done!")}
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
(let [[m1 _] (update-fn {:loading true :message "Loading..."} [:key {:char \space}])]
|
(let [result (update-fn {:model {:loading true :message "Loading..."}
|
||||||
(is (false? (:loading m1)))
|
:event {:type :key :key \space}})]
|
||||||
(is (= "Done!" (:message m1)))))))
|
(is (false? (:loading (:model result))))
|
||||||
|
(is (= "Done!" (:message (:model result))))))))
|
||||||
|
|
||||||
(deftest spinner-restart-test
|
(deftest spinner-restart-test
|
||||||
(testing "spinner r key restarts animation"
|
(testing "spinner r key restarts animation"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg "r")
|
(if (ev/key= event \r)
|
||||||
[(assoc model :loading true :frame 0 :message "Loading...")
|
{:model (assoc model :loading true :frame 0 :message "Loading...")
|
||||||
(tui/after 80 :spinner-frame)]
|
:events [(ev/delayed-event 80 {:type :spinner-frame})]}
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
(let [[m1 c1] (update-fn {:loading false :frame 100 :message "Done!"} [:key {:char \r}])]
|
(let [result (update-fn {:model {:loading false :frame 100 :message "Done!"}
|
||||||
(is (true? (:loading m1)))
|
:event {:type :key :key \r}})]
|
||||||
(is (= 0 (:frame m1)))
|
(is (true? (:loading (:model result))))
|
||||||
(is (= "Loading..." (:message m1)))
|
(is (= 0 (:frame (:model result))))
|
||||||
(is (fn? c1))))))
|
(is (= "Loading..." (:message (:model result))))
|
||||||
|
(is (= 1 (count (:events result))))))))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; LIST SELECTION EXAMPLE TESTS
|
;; LIST SELECTION EXAMPLE TESTS
|
||||||
@@ -320,25 +333,25 @@
|
|||||||
(deftest list-selection-cursor-navigation-test
|
(deftest list-selection-cursor-navigation-test
|
||||||
(testing "cursor navigation respects bounds"
|
(testing "cursor navigation respects bounds"
|
||||||
(let [items ["A" "B" "C" "D" "E"]
|
(let [items ["A" "B" "C" "D" "E"]
|
||||||
update-fn (fn [{:keys [cursor] :as model} msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :cursor #(max 0 (dec %))) nil]
|
{:model (update model :cursor #(max 0 (dec %)))}
|
||||||
|
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
|
||||||
|
|
||||||
:else [model nil]))]
|
:else {:model model}))]
|
||||||
|
|
||||||
;; Move down through list
|
;; Move down through list
|
||||||
(let [m0 {:cursor 0}
|
(let [m0 {:cursor 0}
|
||||||
[m1 _] (update-fn m0 [:key :down])
|
m1 (:model (update-fn {:model m0 :event {:type :key :key :down}}))
|
||||||
[m2 _] (update-fn m1 [:key :down])
|
m2 (:model (update-fn {:model m1 :event {:type :key :key :down}}))
|
||||||
[m3 _] (update-fn m2 [:key :down])
|
m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
|
||||||
[m4 _] (update-fn m3 [:key :down])
|
m4 (:model (update-fn {:model m3 :event {:type :key :key :down}}))
|
||||||
[m5 _] (update-fn m4 [:key :down])] ; Should stop at 4
|
m5 (:model (update-fn {:model m4 :event {:type :key :key :down}}))]
|
||||||
(is (= 1 (:cursor m1)))
|
(is (= 1 (:cursor m1)))
|
||||||
(is (= 2 (:cursor m2)))
|
(is (= 2 (:cursor m2)))
|
||||||
(is (= 3 (:cursor m3)))
|
(is (= 3 (:cursor m3)))
|
||||||
@@ -346,47 +359,49 @@
|
|||||||
(is (= 4 (:cursor m5)))) ; Clamped at max
|
(is (= 4 (:cursor m5)))) ; Clamped at max
|
||||||
|
|
||||||
;; Move up from top
|
;; Move up from top
|
||||||
(let [[m1 _] (update-fn {:cursor 0} [:key :up])]
|
(let [result (update-fn {:model {:cursor 0} :event {:type :key :key :up}})]
|
||||||
(is (= 0 (:cursor m1))))))) ; Clamped at 0
|
(is (= 0 (:cursor (:model result)))))))) ; Clamped at 0
|
||||||
|
|
||||||
(deftest list-selection-toggle-test
|
(deftest list-selection-toggle-test
|
||||||
(testing "space toggles selection"
|
(testing "space toggles selection"
|
||||||
(let [update-fn (fn [{:keys [cursor] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg " ")
|
(if (ev/key= event \space)
|
||||||
[(update model :selected
|
{:model (update model :selected
|
||||||
#(if (contains? % cursor)
|
#(let [cursor (:cursor model)]
|
||||||
|
(if (contains? % cursor)
|
||||||
(disj % cursor)
|
(disj % cursor)
|
||||||
(conj % cursor)))
|
(conj % cursor))))}
|
||||||
nil]
|
{:model model}))]
|
||||||
[model nil]))]
|
|
||||||
|
|
||||||
;; Select item
|
;; Select item
|
||||||
(let [[m1 _] (update-fn {:cursor 0 :selected #{}} [:key {:char \space}])]
|
(let [result (update-fn {:model {:cursor 0 :selected #{}}
|
||||||
(is (= #{0} (:selected m1))))
|
:event {:type :key :key \space}})]
|
||||||
|
(is (= #{0} (:selected (:model result)))))
|
||||||
|
|
||||||
;; Select multiple items
|
;; Select multiple items
|
||||||
(let [m0 {:cursor 0 :selected #{}}
|
(let [m0 {:cursor 0 :selected #{}}
|
||||||
[m1 _] (update-fn m0 [:key {:char \space}])
|
m1 (:model (update-fn {:model m0 :event {:type :key :key \space}}))
|
||||||
m1' (assoc m1 :cursor 2)
|
m2 (:model (update-fn {:model (assoc m1 :cursor 2) :event {:type :key :key \space}}))
|
||||||
[m2 _] (update-fn m1' [:key {:char \space}])
|
m3 (:model (update-fn {:model (assoc m2 :cursor 4) :event {:type :key :key \space}}))]
|
||||||
m2' (assoc m2 :cursor 4)
|
|
||||||
[m3 _] (update-fn m2' [:key {:char \space}])]
|
|
||||||
(is (= #{0 2 4} (:selected m3))))
|
(is (= #{0 2 4} (:selected m3))))
|
||||||
|
|
||||||
;; Deselect item
|
;; Deselect item
|
||||||
(let [[m1 _] (update-fn {:cursor 1 :selected #{1 2}} [:key {:char \space}])]
|
(let [result (update-fn {:model {:cursor 1 :selected #{1 2}}
|
||||||
(is (= #{2} (:selected m1)))))))
|
:event {:type :key :key \space}})]
|
||||||
|
(is (= #{2} (:selected (:model result))))))))
|
||||||
|
|
||||||
(deftest list-selection-submit-test
|
(deftest list-selection-submit-test
|
||||||
(testing "enter submits selection"
|
(testing "enter submits selection"
|
||||||
(let [update-fn (fn [model msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(if (tui/key= msg :enter)
|
(if (ev/key= event :enter)
|
||||||
[(assoc model :submitted true) tui/quit]
|
{:model (assoc model :submitted true)
|
||||||
[model nil]))]
|
:events [(ev/quit)]}
|
||||||
|
{:model model}))]
|
||||||
|
|
||||||
(let [[m1 c1] (update-fn {:selected #{0 2} :submitted false} [:key :enter])]
|
(let [result (update-fn {:model {:selected #{0 2} :submitted false}
|
||||||
(is (true? (:submitted m1)))
|
:event {:type :key :key :enter}})]
|
||||||
(is (= tui/quit c1))))))
|
(is (true? (:submitted (:model result))))
|
||||||
|
(is (= [(ev/quit)] (:events result)))))))
|
||||||
|
|
||||||
(deftest list-selection-view-item-count-test
|
(deftest list-selection-view-item-count-test
|
||||||
(testing "view shows correct item count"
|
(testing "view shows correct item count"
|
||||||
@@ -416,86 +431,93 @@
|
|||||||
(deftest views-menu-navigation-test
|
(deftest views-menu-navigation-test
|
||||||
(testing "menu view cursor navigation"
|
(testing "menu view cursor navigation"
|
||||||
(let [items [{:name "A"} {:name "B"} {:name "C"} {:name "D"}]
|
(let [items [{:name "A"} {:name "B"} {:name "C"} {:name "D"}]
|
||||||
update-fn (fn [{:keys [cursor] :as model} msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :up)
|
(or (ev/key= event :up)
|
||||||
(tui/key= msg "k"))
|
(ev/key= event \k))
|
||||||
[(update model :cursor #(max 0 (dec %))) nil]
|
{:model (update model :cursor #(max 0 (dec %)))}
|
||||||
|
|
||||||
(or (tui/key= msg :down)
|
(or (ev/key= event :down)
|
||||||
(tui/key= msg "j"))
|
(ev/key= event \j))
|
||||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
|
||||||
|
|
||||||
:else [model nil]))]
|
:else {:model model}))]
|
||||||
|
|
||||||
;; Navigate down
|
;; Navigate down
|
||||||
(let [[m1 _] (update-fn {:cursor 0} [:key {:char \j}])]
|
(let [result (update-fn {:model {:cursor 0} :event {:type :key :key \j}})]
|
||||||
(is (= 1 (:cursor m1))))
|
(is (= 1 (:cursor (:model result)))))
|
||||||
|
|
||||||
;; Navigate up
|
;; Navigate up
|
||||||
(let [[m1 _] (update-fn {:cursor 2} [:key {:char \k}])]
|
(let [result (update-fn {:model {:cursor 2} :event {:type :key :key \k}})]
|
||||||
(is (= 1 (:cursor m1)))))))
|
(is (= 1 (:cursor (:model result))))))))
|
||||||
|
|
||||||
(deftest views-state-transitions-test
|
(deftest views-state-transitions-test
|
||||||
(testing "all view state transitions"
|
(testing "all view state transitions"
|
||||||
(let [items [{:name "Profile"} {:name "Settings"}]
|
(let [items [{:name "Profile"} {:name "Settings"}]
|
||||||
update-fn (fn [{:keys [view cursor] :as model} msg]
|
update-fn (fn [{:keys [model event]}]
|
||||||
(case view
|
(case (:view model)
|
||||||
:menu
|
:menu
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg :enter)
|
(ev/key= event :enter)
|
||||||
[(assoc model :view :detail :selected (nth items cursor)) nil]
|
{:model (assoc model :view :detail :selected (nth items (:cursor model)))}
|
||||||
(tui/key= msg "q")
|
(ev/key= event \q)
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
:else [model nil])
|
:else {:model model})
|
||||||
|
|
||||||
:detail
|
:detail
|
||||||
(cond
|
(cond
|
||||||
(or (tui/key= msg :escape)
|
(or (ev/key= event :escape)
|
||||||
(tui/key= msg "b"))
|
(ev/key= event \b))
|
||||||
[(assoc model :view :menu :selected nil) nil]
|
{:model (assoc model :view :menu :selected nil)}
|
||||||
(tui/key= msg "q")
|
(ev/key= event \q)
|
||||||
[(assoc model :view :confirm) nil]
|
{:model (assoc model :view :confirm)}
|
||||||
:else [model nil])
|
:else {:model model})
|
||||||
|
|
||||||
:confirm
|
:confirm
|
||||||
(cond
|
(cond
|
||||||
(tui/key= msg "y")
|
(ev/key= event \y)
|
||||||
[model tui/quit]
|
{:model model :events [(ev/quit)]}
|
||||||
(or (tui/key= msg "n")
|
(or (ev/key= event \n)
|
||||||
(tui/key= msg :escape))
|
(ev/key= event :escape))
|
||||||
[(assoc model :view :detail) nil]
|
{:model (assoc model :view :detail)}
|
||||||
:else [model nil])))]
|
:else {:model model})))]
|
||||||
|
|
||||||
;; Menu -> Detail via enter
|
;; Menu -> Detail via enter
|
||||||
(let [[m1 _] (update-fn {:view :menu :cursor 0 :items items} [:key :enter])]
|
(let [result (update-fn {:model {:view :menu :cursor 0 :items items}
|
||||||
(is (= :detail (:view m1)))
|
:event {:type :key :key :enter}})]
|
||||||
(is (= "Profile" (:name (:selected m1)))))
|
(is (= :detail (:view (:model result))))
|
||||||
|
(is (= "Profile" (:name (:selected (:model result))))))
|
||||||
|
|
||||||
;; Detail -> Menu via escape
|
;; Detail -> Menu via escape
|
||||||
(let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key :escape])]
|
(let [result (update-fn {:model {:view :detail :selected {:name "X"}}
|
||||||
(is (= :menu (:view m1)))
|
:event {:type :key :key :escape}})]
|
||||||
(is (nil? (:selected m1))))
|
(is (= :menu (:view (:model result))))
|
||||||
|
(is (nil? (:selected (:model result)))))
|
||||||
|
|
||||||
;; Detail -> Menu via b
|
;; Detail -> Menu via b
|
||||||
(let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key {:char \b}])]
|
(let [result (update-fn {:model {:view :detail :selected {:name "X"}}
|
||||||
(is (= :menu (:view m1))))
|
:event {:type :key :key \b}})]
|
||||||
|
(is (= :menu (:view (:model result)))))
|
||||||
|
|
||||||
;; Detail -> Confirm via q
|
;; Detail -> Confirm via q
|
||||||
(let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])]
|
(let [result (update-fn {:model {:view :detail}
|
||||||
(is (= :confirm (:view m1))))
|
:event {:type :key :key \q}})]
|
||||||
|
(is (= :confirm (:view (:model result)))))
|
||||||
|
|
||||||
;; Confirm -> Quit via y
|
;; Confirm -> Quit via y
|
||||||
(let [[_ c1] (update-fn {:view :confirm} [:key {:char \y}])]
|
(let [result (update-fn {:model {:view :confirm}
|
||||||
(is (= tui/quit c1)))
|
:event {:type :key :key \y}})]
|
||||||
|
(is (= [(ev/quit)] (:events result))))
|
||||||
|
|
||||||
;; Confirm -> Detail via n
|
;; Confirm -> Detail via n
|
||||||
(let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])]
|
(let [result (update-fn {:model {:view :confirm}
|
||||||
(is (= :detail (:view m1))))
|
:event {:type :key :key \n}})]
|
||||||
|
(is (= :detail (:view (:model result)))))
|
||||||
|
|
||||||
;; Confirm -> Detail via escape
|
;; Confirm -> Detail via escape
|
||||||
(let [[m1 _] (update-fn {:view :confirm} [:key :escape])]
|
(let [result (update-fn {:model {:view :confirm}
|
||||||
(is (= :detail (:view m1)))))))
|
:event {:type :key :key :escape}})]
|
||||||
|
(is (= :detail (:view (:model result))))))))
|
||||||
|
|
||||||
;; =============================================================================
|
;; =============================================================================
|
||||||
;; HTTP EXAMPLE TESTS
|
;; HTTP EXAMPLE TESTS
|
||||||
@@ -514,66 +536,58 @@
|
|||||||
|
|
||||||
(deftest http-state-machine-test
|
(deftest http-state-machine-test
|
||||||
(testing "http state transitions"
|
(testing "http state transitions"
|
||||||
(let [update-fn (fn [{:keys [state url] :as model} msg]
|
(let [update-fn (fn [{:keys [model event]}]
|
||||||
(cond
|
(cond
|
||||||
;; Start request
|
;; Start request
|
||||||
(and (= state :idle)
|
(and (= (:state model) :idle)
|
||||||
(tui/key= msg :enter))
|
(ev/key= event :enter))
|
||||||
[(assoc model :state :loading)
|
{:model (assoc model :state :loading)
|
||||||
(fn [] [:http-success 200])]
|
:events [(ev/shell ["curl" "-s" (:url model)] {:type :http-result})]}
|
||||||
|
|
||||||
;; Reset
|
;; Reset
|
||||||
(tui/key= msg "r")
|
(ev/key= event \r)
|
||||||
[(assoc model :state :idle :status nil :error nil) nil]
|
{:model (assoc model :state :idle :status nil :error nil)}
|
||||||
|
|
||||||
;; HTTP success
|
;; HTTP result
|
||||||
(= (first msg) :http-success)
|
(= (:type event) :http-result)
|
||||||
[(assoc model :state :success :status (second msg)) nil]
|
(if (get-in event [:result :success])
|
||||||
|
{:model (assoc model :state :success :status 200)}
|
||||||
;; HTTP error
|
{:model (assoc model :state :error :error (get-in event [:result :err]))})
|
||||||
(= (first msg) :http-error)
|
|
||||||
[(assoc model :state :error :error (second msg)) nil]
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
[model nil]))]
|
{:model model}))]
|
||||||
|
|
||||||
;; Idle -> Loading via enter
|
;; Idle -> Loading via enter
|
||||||
(let [[m1 c1] (update-fn {:state :idle :url "http://test.com"} [:key :enter])]
|
(let [result (update-fn {:model {:state :idle :url "http://test.com"}
|
||||||
(is (= :loading (:state m1)))
|
:event {:type :key :key :enter}})]
|
||||||
(is (fn? c1)))
|
(is (= :loading (:state (:model result))))
|
||||||
|
(is (= 1 (count (:events result)))))
|
||||||
|
|
||||||
;; Enter ignored when not idle
|
;; Enter ignored when not idle
|
||||||
(let [[m1 c1] (update-fn {:state :loading} [:key :enter])]
|
(let [result (update-fn {:model {:state :loading}
|
||||||
(is (= :loading (:state m1)))
|
:event {:type :key :key :enter}})]
|
||||||
(is (nil? c1)))
|
(is (= :loading (:state (:model result))))
|
||||||
|
(is (nil? (:events result))))
|
||||||
|
|
||||||
;; Loading -> Success
|
;; Loading -> Success
|
||||||
(let [[m1 _] (update-fn {:state :loading} [:http-success 200])]
|
(let [result (update-fn {:model {:state :loading}
|
||||||
(is (= :success (:state m1)))
|
:event {:type :http-result :result {:success true :out "200"}}})]
|
||||||
(is (= 200 (:status m1))))
|
(is (= :success (:state (:model result))))
|
||||||
|
(is (= 200 (:status (:model result)))))
|
||||||
|
|
||||||
;; Loading -> Error
|
;; Loading -> Error
|
||||||
(let [[m1 _] (update-fn {:state :loading} [:http-error "Connection refused"])]
|
(let [result (update-fn {:model {:state :loading}
|
||||||
(is (= :error (:state m1)))
|
:event {:type :http-result :result {:success false :err "Connection refused"}}})]
|
||||||
(is (= "Connection refused" (:error m1))))
|
(is (= :error (:state (:model result))))
|
||||||
|
(is (= "Connection refused" (:error (:model result)))))
|
||||||
|
|
||||||
;; Reset from any state
|
;; Reset from any state
|
||||||
(doseq [state [:idle :loading :success :error]]
|
(doseq [state [:idle :loading :success :error]]
|
||||||
(let [[m1 _] (update-fn {:state state :status 200 :error "err"} [:key {:char \r}])]
|
(let [result (update-fn {:model {:state state :status 200 :error "err"}
|
||||||
(is (= :idle (:state m1)))
|
:event {:type :key :key \r}})]
|
||||||
(is (nil? (:status m1)))
|
(is (= :idle (:state (:model result))))
|
||||||
(is (nil? (:error m1))))))))
|
(is (nil? (:status (:model result))))
|
||||||
|
(is (nil? (:error (:model result)))))))))
|
||||||
(deftest http-fetch-command-test
|
|
||||||
(testing "fetch command creates async function"
|
|
||||||
(let [fetch-url (fn [url]
|
|
||||||
(fn []
|
|
||||||
;; Simulate success
|
|
||||||
[:http-success 200]))]
|
|
||||||
|
|
||||||
(let [cmd (fetch-url "https://test.com")]
|
|
||||||
(is (fn? cmd))
|
|
||||||
(is (= [:http-success 200] (cmd)))))))
|
|
||||||
|
|
||||||
(deftest http-view-states-test
|
(deftest http-view-states-test
|
||||||
(testing "http view renders different states"
|
(testing "http view renders different states"
|
||||||
|
|||||||
Reference in New Issue
Block a user