update examples. fix bugs

This commit is contained in:
2026-02-03 12:53:52 -05:00
parent 9150c90ad1
commit 426a0c4715
15 changed files with 867 additions and 1148 deletions
+233 -270
View File
@@ -4,237 +4,221 @@
(:require [clojure.test :refer [deftest testing is are]]
[clojure.string :as str]
[tui.core :as tui]
[tui.events :as ev]
[tui.render :as render]
[tui.input :as input]))
;; =============================================================================
;; KEY MATCHING TESTS (tui/key=)
;; KEY MATCHING TESTS (ev/key=)
;; Patterns from: counter, timer, list-selection, spinner, views, http
;; =============================================================================
(deftest key=-character-keys-test
(testing "from counter: matching q for quit"
(is (tui/key= [:key {:char \q}] "q"))
(is (not (tui/key= [:key {:char \a}] "q"))))
(is (ev/key= {:type :key :key \q} \q))
(is (not (ev/key= {:type :key :key \a} \q))))
(testing "from counter: matching k/j for navigation"
(is (tui/key= [:key {:char \k}] "k"))
(is (tui/key= [:key {:char \j}] "j")))
(is (ev/key= {:type :key :key \k} \k))
(is (ev/key= {:type :key :key \j} \j)))
(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"
(is (tui/key= [:key {:char \space}] " "))
(is (tui/key= [:key {:char \space}] " ")))
(is (ev/key= {:type :key :key \space} \space)))
(testing "from views: matching b for back, y/n for confirm"
(is (tui/key= [:key {:char \b}] "b"))
(is (tui/key= [:key {:char \y}] "y"))
(is (tui/key= [:key {:char \n}] "n"))))
(is (ev/key= {:type :key :key \b} \b))
(is (ev/key= {:type :key :key \y} \y))
(is (ev/key= {:type :key :key \n} \n))))
(deftest key=-arrow-keys-test
(testing "from counter/list-selection: up/down arrows"
(is (tui/key= [:key :up] :up))
(is (tui/key= [:key :down] :down))
(is (not (tui/key= [:key :up] :down)))
(is (not (tui/key= [:key :left] :up))))
(is (ev/key= {:type :key :key :up} :up))
(is (ev/key= {:type :key :key :down} :down))
(is (not (ev/key= {:type :key :key :up} :down)))
(is (not (ev/key= {:type :key :key :left} :up))))
(testing "left/right arrows"
(is (tui/key= [:key :left] :left))
(is (tui/key= [:key :right] :right))))
(is (ev/key= {:type :key :key :left} :left))
(is (ev/key= {:type :key :key :right} :right))))
(deftest key=-special-keys-test
(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"
(is (tui/key= [:key :escape] :escape)))
(is (ev/key= {:type :key :key :escape} :escape)))
(testing "from spinner: tab key"
(is (tui/key= [:key :tab] :tab)))
(is (ev/key= {:type :key :key :tab} :tab)))
(testing "backspace key"
(is (tui/key= [:key :backspace] :backspace))))
(is (ev/key= {:type :key :key :backspace} :backspace))))
(deftest key=-ctrl-combos-test
(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"
(is (tui/key= [:key {:ctrl true :char \x}] [:ctrl \x]))
(is (tui/key= [:key {:ctrl true :char \z}] [:ctrl \z]))
(is (tui/key= [:key {:ctrl true :char \a}] [:ctrl \a])))
(is (ev/key= {:type :key :key \x :modifiers #{:ctrl}} \x #{:ctrl}))
(is (ev/key= {:type :key :key \z :modifiers #{:ctrl}} \z #{:ctrl}))
(is (ev/key= {:type :key :key \a :modifiers #{:ctrl}} \a #{:ctrl})))
(testing "ctrl combo does not match plain char"
(is (not (tui/key= [:key {:ctrl true :char \c}] "c")))
(is (not (tui/key= [:key {:char \c}] [:ctrl \c])))))
(is (not (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c)))
(is (not (ev/key= {:type :key :key \c} \c #{:ctrl})))))
(deftest key=-alt-combos-test
(testing "alt+char combinations"
(is (tui/key= [:key {:alt true :char \x}] [:alt \x]))
(is (tui/key= [:key {:alt true :char \a}] [:alt \a])))
(is (ev/key= {:type :key :key \x :modifiers #{:alt}} \x #{:alt}))
(is (ev/key= {:type :key :key \a :modifiers #{:alt}} \a #{:alt})))
(testing "alt combo does not match plain char"
(is (not (tui/key= [:key {:alt true :char \x}] "x")))
(is (not (tui/key= [:key {:char \x}] [:alt \x])))))
(is (not (ev/key= {:type :key :key \x :modifiers #{:alt}} \x)))
(is (not (ev/key= {:type :key :key \x} \x #{:alt})))))
(deftest key=-non-key-messages-test
(testing "from timer/spinner: tick messages are not keys"
(is (not (tui/key= [:tick 123456789] "q")))
(is (not (tui/key= [:tick 123456789] :enter))))
(deftest key=-non-key-events-test
(testing "from timer/spinner: custom events are not keys"
(is (not (ev/key= {:type :timer-tick} \q)))
(is (not (ev/key= {:type :timer-tick} :enter))))
(testing "from http: custom messages are not keys"
(is (not (tui/key= [:http-success 200] "q")))
(is (not (tui/key= [:http-error "timeout"] :enter))))
(testing "from http: custom events are not keys"
(is (not (ev/key= {:type :http-result :status 200} \q)))
(is (not (ev/key= {:type :http-error :error "timeout"} :enter))))
(testing "quit command is not a key"
(is (not (tui/key= [:quit] "q")))))
(testing "quit event is not a key"
(is (not (ev/key= {:type :quit} \q)))))
;; =============================================================================
;; COMMAND TESTS
;; EVENT CONSTRUCTOR TESTS
;; Patterns from: timer, spinner, http
;; =============================================================================
(deftest quit-command-test
(testing "from all examples: tui/quit is [:quit]"
(is (= [:quit] tui/quit))
(is (vector? tui/quit))
(is (= :quit (first tui/quit)))))
(deftest quit-event-test
(testing "from all examples: ev/quit creates quit event"
(is (= {:type :quit} (ev/quit)))
(is (map? (ev/quit)))
(is (= :quit (:type (ev/quit))))))
(deftest after-command-test
(testing "from timer: after creates function"
(let [cmd (tui/after 1000 :timer-tick)]
(is (fn? cmd))))
(deftest delayed-event-test
(testing "from timer: delayed-event creates delayed-event event"
(let [event (ev/delayed-event 1000 {:type :timer-tick})]
(is (= :delayed-event (:type event)))
(is (= 1000 (:ms event)))
(is (= {:type :timer-tick} (:event event)))))
(testing "from spinner: after creates function"
(let [cmd (tui/after 80 :spinner-frame)]
(is (fn? cmd))))
(testing "from spinner: delayed-event with short interval"
(let [event (ev/delayed-event 80 {:type :spinner-frame})]
(is (= :delayed-event (:type event)))
(is (= 80 (:ms event))))))
(testing "after with zero delay returns message immediately"
(is (= :timer-tick ((tui/after 0 :timer-tick))))
(is (= [:my-tick {:id 1}] ((tui/after 0 [:my-tick {:id 1}]))))))
(deftest batch-command-test
(testing "batch two commands"
(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)))))
(deftest batch-event-test
(testing "batch multiple events"
(let [event (ev/batch {:type :msg1} {:type :msg2})]
(is (= :batch (:type event)))
(is (= 2 (count (:events event))))
(is (= [{:type :msg1} {:type :msg2}] (:events event)))))
(testing "batch filters nil"
(let [cmd (tui/batch nil (tui/send-msg :msg1) nil)]
(is (= :batch (first cmd)))
(is (= 2 (count cmd))))
(is (= [:batch] (tui/batch nil nil nil))))
(let [event (ev/batch nil {:type :msg1} nil)]
(is (= :batch (:type event)))
(is (= 1 (count (:events event)))))
(is (nil? (ev/batch nil nil nil))))
(testing "batch with single command"
(is (= [:batch [:quit]] (tui/batch tui/quit)))))
(testing "batch with single event"
(let [event (ev/batch {:type :msg1})]
(is (= :batch (:type event)))
(is (= 1 (count (:events event)))))))
(deftest sequentially-command-test
(testing "sequentially two commands"
(let [cmd (tui/sequentially (tui/after 100 :tick1) tui/quit)]
(is (= :seq (first cmd)))
(is (= 3 (count cmd)))
(is (fn? (second cmd)))
(is (= [:quit] (last cmd)))))
(deftest sequential-event-test
(testing "sequential multiple events"
(let [event (ev/sequential {:type :msg1} {:type :msg2})]
(is (= :sequential (:type event)))
(is (= 2 (count (:events event))))))
(testing "sequentially filters nil"
(let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)]
(is (= :seq (first cmd)))
(is (= 2 (count cmd)))))
(testing "sequential filters nil"
(let [event (ev/sequential nil {:type :msg1} nil)]
(is (= :sequential (:type event)))
(is (= 1 (count (:events event))))))
(testing "sequentially with functions"
(let [f (fn [] :msg)
cmd (tui/sequentially f tui/quit)]
(is (= 3 (count cmd)))
(is (= :seq (first cmd)))
(is (fn? (second cmd))))))
(testing "sequential with delay and quit"
(let [event (ev/sequential
(ev/delayed-event 100 {:type :tick})
(ev/quit))]
(is (= :sequential (:type event)))
(is (= 2 (count (:events event)))))))
(deftest send-msg-command-test
(testing "from http pattern: send-msg creates function"
(let [cmd (tui/send-msg [:http-success 200])]
(is (fn? cmd))
(is (= [:http-success 200] (cmd)))))
(deftest shell-event-test
(testing "shell creates shell event"
(let [event (ev/shell ["git" "status"] {:type :git-result})]
(is (= :shell (:type event)))
(is (= ["git" "status"] (:cmd event)))
(is (= {:type :git-result} (:event event))))))
(testing "send-msg with map"
(let [cmd (tui/send-msg {:type :custom :data 42})]
(is (= {:type :custom :data 42} (cmd)))))
(testing "send-msg with keyword"
(let [cmd (tui/send-msg :done)]
(is (= :done (cmd))))))
(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)))))
(deftest debounce-event-test
(testing "debounce creates debounce event"
(let [event (ev/debounce :search 300 {:type :do-search :query "test"})]
(is (= :debounce (:type event)))
(is (= :search (:id event)))
(is (= 300 (:ms event)))
(is (= {:type :do-search :query "test"} (:event event))))))
;; =============================================================================
;; UPDATE FUNCTION PATTERNS
;; Testing the [model cmd] return contract
;; Testing the {:model m :events [...]} return contract
;; =============================================================================
(deftest update-returns-tuple-test
(testing "update always returns [model cmd] tuple"
(deftest update-returns-map-test
(testing "update always returns {:model m :events [...]}"
(let [model {:count 0}
;; Counter-style update
update-fn (fn [m msg]
update-fn (fn [{:keys [model event]}]
(cond
(tui/key= msg "q") [m tui/quit]
(tui/key= msg :up) [(update m :count inc) nil]
:else [m nil]))]
(ev/key= event \q) {:model model :events [(ev/quit)]}
(ev/key= event :up) {:model (update model :count inc)}
:else {:model model}))]
;; Quit returns original model + quit command
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
(is (= model new-model))
(is (= tui/quit cmd)))
;; Quit returns original model + quit event
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \q}})]
(is (= {:count 0} model))
(is (= [{:type :quit}] events)))
;; Up returns modified model + nil command
(let [[new-model cmd] (update-fn model [:key :up])]
(is (= {:count 1} new-model))
(is (nil? cmd)))
;; Up returns modified model, no events
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key :up}})]
(is (= {:count 1} model))
(is (nil? events)))
;; Unknown key returns unchanged model + nil command
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
(is (= model new-model))
(is (nil? cmd))))))
;; Unknown key returns unchanged model, no events
(let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \x}})]
(is (= {:count 0} model))
(is (nil? events))))))
(deftest counter-update-pattern-test
(testing "counter increment/decrement pattern"
(let [update-fn (fn [{:keys [count] :as model} msg]
(let [update-fn (fn [{:keys [model event]}]
(cond
(or (tui/key= msg :up)
(tui/key= msg "k"))
[(update model :count inc) nil]
(or (ev/key= event :up)
(ev/key= event \k))
{:model (update model :count inc)}
(or (tui/key= msg :down)
(tui/key= msg "j"))
[(update model :count dec) nil]
(or (ev/key= event :down)
(ev/key= event \j))
{:model (update model :count dec)}
(tui/key= msg "r")
[(assoc model :count 0) nil]
(ev/key= event \r)
{:model (assoc model :count 0)}
:else
[model nil]))]
{:model model}))]
;; Test sequence: up, up, down, reset
(let [m0 {:count 0}
[m1 _] (update-fn m0 [:key :up])
[m2 _] (update-fn m1 [:key {:char \k}])
[m3 _] (update-fn m2 [:key :down])
[m4 _] (update-fn m3 [:key {:char \r}])]
m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
m2 (:model (update-fn {:model m1 :event {:type :key :key \k}}))
m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
m4 (:model (update-fn {:model m3 :event {:type :key :key \r}}))]
(is (= 1 (:count m1)))
(is (= 2 (:count m2)))
(is (= 1 (:count m3)))
@@ -242,68 +226,69 @@
(deftest timer-update-pattern-test
(testing "timer tick handling pattern"
(let [update-fn (fn [{:keys [seconds running] :as model} msg]
(let [update-fn (fn [{:keys [model event]}]
(cond
(= msg :timer-tick)
(if running
(let [new-seconds (dec seconds)]
(= (:type event) :timer-tick)
(if (:running model)
(let [new-seconds (dec (:seconds model))]
(if (<= new-seconds 0)
[(assoc model :seconds 0 :done true :running false) nil]
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)]))
[model nil])
{:model (assoc model :seconds 0 :done true :running false)}
{:model (assoc model :seconds new-seconds)
:events [(ev/delayed-event 1000 {:type :timer-tick})]}))
{:model model})
(tui/key= msg " ")
(let [new-running (not running)]
[(assoc model :running new-running)
(when new-running (tui/after 1000 :timer-tick))])
(ev/key= event \space)
(let [new-running (not (:running model))]
{:model (assoc model :running new-running)
:events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
:else
[model nil]))]
{:model model}))]
;; Test tick countdown
(let [m0 {:seconds 3 :running true :done false}
[m1 c1] (update-fn m0 :timer-tick)
[m2 c2] (update-fn m1 :timer-tick)
[m3 c3] (update-fn m2 :timer-tick)]
(is (= 2 (:seconds m1)))
(is (fn? c1))
(is (= 1 (:seconds m2)))
(is (fn? c2))
(is (= 0 (:seconds m3)))
(is (:done m3))
(is (not (:running m3)))
(is (nil? c3)))
r1 (update-fn {:model m0 :event {:type :timer-tick}})
r2 (update-fn {:model (:model r1) :event {:type :timer-tick}})
r3 (update-fn {:model (:model r2) :event {:type :timer-tick}})]
(is (= 2 (:seconds (:model r1))))
(is (= 1 (count (:events r1))))
(is (= 1 (:seconds (:model r2))))
(is (= 1 (count (:events r2))))
(is (= 0 (:seconds (:model r3))))
(is (:done (:model r3)))
(is (not (:running (:model r3))))
(is (nil? (:events r3))))
;; Test pause/resume
(let [m0 {:seconds 5 :running true :done false}
[m1 c1] (update-fn m0 [:key {:char \space}])
[m2 c2] (update-fn m1 [:key {:char \space}])]
(is (not (:running m1)))
(is (nil? c1))
(is (:running m2))
(is (fn? c2))))))
r1 (update-fn {:model m0 :event {:type :key :key \space}})
r2 (update-fn {:model (:model r1) :event {:type :key :key \space}})]
(is (not (:running (:model r1))))
(is (nil? (:events r1)))
(is (:running (:model r2)))
(is (= 1 (count (:events r2))))))))
(deftest list-selection-update-pattern-test
(testing "cursor navigation with bounds"
(let [items ["a" "b" "c" "d"]
update-fn (fn [{:keys [cursor] :as model} msg]
update-fn (fn [{:keys [model event]}]
(cond
(or (tui/key= msg :up) (tui/key= msg "k"))
[(update model :cursor #(max 0 (dec %))) nil]
(or (ev/key= event :up) (ev/key= event \k))
{:model (update model :cursor #(max 0 (dec %)))}
(or (tui/key= msg :down) (tui/key= msg "j"))
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
(or (ev/key= event :down) (ev/key= event \j))
{:model (update model :cursor #(min (dec (count items)) (inc %)))}
:else
[model nil]))]
{:model model}))]
;; Test bounds
(let [m0 {:cursor 0}
[m1 _] (update-fn m0 [:key :up]) ; Can't go below 0
[m2 _] (update-fn m1 [:key :down])
[m3 _] (update-fn m2 [:key :down])
[m4 _] (update-fn m3 [:key :down])
[m5 _] (update-fn m4 [:key :down])] ; Can't go above 3
m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
m2 (:model (update-fn {:model m1 :event {:type :key :key :down}}))
m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
m4 (:model (update-fn {:model m3 :event {:type :key :key :down}}))
m5 (:model (update-fn {:model m4 :event {:type :key :key :down}}))]
(is (= 0 (:cursor m1)))
(is (= 1 (:cursor m2)))
(is (= 2 (:cursor m3)))
@@ -311,108 +296,109 @@
(is (= 3 (:cursor m5))))))
(testing "toggle selection pattern"
(let [update-fn (fn [{:keys [cursor] :as model} msg]
(if (tui/key= msg " ")
[(update model :selected
#(if (contains? % cursor)
(disj % cursor)
(conj % cursor)))
nil]
[model nil]))]
(let [update-fn (fn [{:keys [model event]}]
(if (ev/key= event \space)
{:model (update model :selected
#(let [cursor (:cursor model)]
(if (contains? % cursor)
(disj % cursor)
(conj % cursor))))}
{:model model}))]
(let [m0 {:cursor 0 :selected #{}}
[m1 _] (update-fn m0 [:key {:char \space}])
[m2 _] (update-fn (assoc m1 :cursor 2) [:key {:char \space}])
[m3 _] (update-fn (assoc m2 :cursor 0) [:key {:char \space}])]
m1 (:model (update-fn {:model m0 :event {:type :key :key \space}}))
m2 (:model (update-fn {:model (assoc m1 :cursor 2) :event {:type :key :key \space}}))
m3 (:model (update-fn {:model (assoc m2 :cursor 0) :event {:type :key :key \space}}))]
(is (= #{0} (:selected m1)))
(is (= #{0 2} (:selected m2)))
(is (= #{2} (:selected m3)))))))
(deftest views-state-machine-pattern-test
(testing "view state transitions"
(let [update-fn (fn [{:keys [view] :as model} msg]
(case view
(let [update-fn (fn [{:keys [model event]}]
(case (:view model)
:menu
(cond
(tui/key= msg :enter)
[(assoc model :view :detail) nil]
(tui/key= msg "q")
[(assoc model :view :confirm) nil]
:else [model nil])
(ev/key= event :enter)
{:model (assoc model :view :detail)}
(ev/key= event \q)
{:model (assoc model :view :confirm)}
:else {:model model})
:detail
(cond
(or (tui/key= msg :escape) (tui/key= msg "b"))
[(assoc model :view :menu) nil]
(tui/key= msg "q")
[(assoc model :view :confirm) nil]
:else [model nil])
(or (ev/key= event :escape) (ev/key= event \b))
{:model (assoc model :view :menu)}
(ev/key= event \q)
{:model (assoc model :view :confirm)}
:else {:model model})
:confirm
(cond
(tui/key= msg "y")
[model tui/quit]
(tui/key= msg "n")
[(assoc model :view :detail) nil]
:else [model nil])))]
(ev/key= event \y)
{:model model :events [(ev/quit)]}
(ev/key= event \n)
{:model (assoc model :view :detail)}
:else {:model model})))]
;; Menu -> Detail -> Confirm -> Quit
(let [m0 {:view :menu}
[m1 _] (update-fn m0 [:key :enter])
[m2 _] (update-fn m1 [:key {:char \q}])
[m3 c3] (update-fn m2 [:key {:char \y}])]
(is (= :detail (:view m1)))
(is (= :confirm (:view m2)))
(is (= tui/quit c3)))
r1 (update-fn {:model m0 :event {:type :key :key :enter}})
r2 (update-fn {:model (:model r1) :event {:type :key :key \q}})
r3 (update-fn {:model (:model r2) :event {:type :key :key \y}})]
(is (= :detail (:view (:model r1))))
(is (= :confirm (:view (:model r2))))
(is (= [{:type :quit}] (:events r3))))
;; Detail -> Menu (back)
(let [m0 {:view :detail}
[m1 _] (update-fn m0 [:key :escape])]
(is (= :menu (:view m1)))))))
r1 (update-fn {:model m0 :event {:type :key :key :escape}})]
(is (= :menu (:view (:model r1))))))))
(deftest http-async-pattern-test
(testing "HTTP state machine pattern"
(let [update-fn (fn [{:keys [state url] :as model} msg]
(let [update-fn (fn [{:keys [model event]}]
(cond
(and (= state :idle) (tui/key= msg :enter))
[(assoc model :state :loading)
(fn [] [:http-success 200])]
(and (= (:state model) :idle) (ev/key= event :enter))
{:model (assoc model :state :loading)
:events [(ev/shell ["curl" "-s" (:url model)]
{:type :http-result})]}
(= (first msg) :http-success)
[(assoc model :state :success :status (second msg)) nil]
(= (:type event) :http-result)
(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)
[(assoc model :state :error :error (second msg)) nil]
(tui/key= msg "r")
[(assoc model :state :idle :status nil :error nil) nil]
(ev/key= event \r)
{:model (assoc model :state :idle :status nil :error nil)}
:else
[model nil]))]
{:model model}))]
;; Idle -> Loading
(let [m0 {:state :idle :url "http://test.com"}
[m1 c1] (update-fn m0 [:key :enter])]
(is (= :loading (:state m1)))
(is (fn? c1)))
r1 (update-fn {:model m0 :event {:type :key :key :enter}})]
(is (= :loading (:state (:model r1))))
(is (= 1 (count (:events r1)))))
;; Loading -> Success
(let [m0 {:state :loading}
[m1 _] (update-fn m0 [:http-success 200])]
(is (= :success (:state m1)))
(is (= 200 (:status m1))))
r1 (update-fn {:model m0 :event {:type :http-result :result {:success true :out "OK"}}})]
(is (= :success (:state (:model r1))))
(is (= 200 (:status (:model r1)))))
;; Loading -> Error
(let [m0 {:state :loading}
[m1 _] (update-fn m0 [:http-error "Connection refused"])]
(is (= :error (:state m1)))
(is (= "Connection refused" (:error m1))))
r1 (update-fn {:model m0 :event {:type :http-result :result {:success false :err "Connection refused"}}})]
(is (= :error (:state (:model r1))))
(is (= "Connection refused" (:error (:model r1)))))
;; Reset
(let [m0 {:state :error :error "timeout"}
[m1 _] (update-fn m0 [:key {:char \r}])]
(is (= :idle (:state m1)))
(is (nil? (:error m1)))))))
r1 (update-fn {:model m0 :event {:type :key :key \r}})]
(is (= :idle (:state (:model r1))))
(is (nil? (:error (:model r1))))))))
;; =============================================================================
;; RENDER TESTS
@@ -565,26 +551,3 @@
(is (str/includes? result "Pizza"))
(is (str/includes? result "[ ] Sushi"))
(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}])))))