diff --git a/examples/counter.clj b/examples/counter.clj index ce3a99d..c63920d 100644 --- a/examples/counter.clj +++ b/examples/counter.clj @@ -1,36 +1,37 @@ (ns examples.counter "Simple counter example - demonstrates basic Elm architecture. Mirrors bubbletea's simple example." - (:require [tui.core :as tui])) + (:require [tui.core :as tui] + [tui.events :as ev])) ;; === Model === (def initial-model {:count 0}) ;; === Update === -(defn update-model [model msg] +(defn update-fn [{:keys [model event]}] (cond ;; Quit on q or ctrl+c - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} ;; Increment on up/k - (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)} ;; Decrement on down/j - (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)} ;; Reset on r - (tui/key= msg "r") - [(assoc model :count 0) nil] + (ev/key= event \r) + {:model (assoc model :count 0)} :else - [model nil])) + {:model model})) ;; === View === (defn view [{:keys [count]} _size] @@ -51,6 +52,6 @@ (defn -main [& _args] (println "Starting counter...") (let [final-model (tui/run {:init initial-model - :update update-model + :update update-fn :view view})] (println "Final count:" (:count final-model)))) diff --git a/examples/http.clj b/examples/http.clj index 2103144..f292958 100644 --- a/examples/http.clj +++ b/examples/http.clj @@ -2,6 +2,7 @@ "HTTP request example - demonstrates async commands. Mirrors bubbletea's http example." (:require [tui.core :as tui] + [tui.events :as ev] [clojure.java.io :as io]) (:import [java.net URL HttpURLConnection])) @@ -29,41 +30,35 @@ :error nil :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 === -(defn update-model [{:keys [url] :as model} msg] - (cond - ;; Quit - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] +(defn update-fn [{:keys [model event]}] + (let [{:keys [url]} model] + (cond + ;; Quit + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} - ;; Enter - start request - (and (= (:state model) :idle) - (tui/key= msg :enter)) - [(assoc model :state :loading) (fetch-url url)] + ;; Enter - start request + (and (= (:state model) :idle) + (ev/key= event :enter)) + {:model (assoc model :state :loading) + :events [(ev/shell ["curl" "-s" "-o" "/dev/null" "-w" "%{http_code}" url] + {:type :http-result})]} - ;; r - retry/reset - (tui/key= msg "r") - [(assoc model :state :idle :status nil :error nil) nil] + ;; r - retry/reset + (ev/key= event \r) + {:model (assoc model :state :idle :status nil :error nil)} - ;; HTTP success - (= (first msg) :http-success) - [(assoc model :state :success :status (second msg)) nil] + ;; HTTP result + (= (:type event) :http-result) + (let [{:keys [success out err]} (:result event)] + (if success + {:model (assoc model :state :success :status (parse-long out))} + {:model (assoc model :state :error :error err)})) - ;; HTTP error - (= (first msg) :http-error) - [(assoc model :state :error :error (second msg)) nil] - - :else - [model nil])) + :else + {:model model}))) ;; === View === (defn view [{:keys [state status error url]} _size] @@ -105,7 +100,7 @@ (defn -main [& _args] (println "Starting HTTP demo...") (let [final (tui/run {:init initial-model - :update update-model + :update update-fn :view view})] (when (= (:state final) :success) (println "Request completed with status:" (:status final))))) diff --git a/examples/list_selection.clj b/examples/list_selection.clj index 5f237e3..84b262f 100644 --- a/examples/list_selection.clj +++ b/examples/list_selection.clj @@ -2,6 +2,7 @@ "List selection example - demonstrates cursor navigation and multi-select. Mirrors bubbletea's list examples." (:require [tui.core :as tui] + [tui.events :as ev] [clojure.string :as str])) ;; === Model === @@ -12,37 +13,38 @@ :submitted false}) ;; === Update === -(defn update-model [{:keys [cursor items] :as model} msg] - (cond - ;; Quit - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] +(defn update-fn [{:keys [model event]}] + (let [{:keys [cursor items]} model] + (cond + ;; Quit + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} - ;; Move up - (or (tui/key= msg :up) - (tui/key= msg "k")) - [(update model :cursor #(max 0 (dec %))) nil] + ;; Move up + (or (ev/key= event :up) + (ev/key= event \k)) + {:model (update model :cursor #(max 0 (dec %)))} - ;; Move down - (or (tui/key= msg :down) - (tui/key= msg "j")) - [(update model :cursor #(min (dec (count items)) (inc %))) nil] + ;; Move down + (or (ev/key= event :down) + (ev/key= event \j)) + {:model (update model :cursor #(min (dec (count items)) (inc %)))} - ;; Toggle selection - (tui/key= msg " ") - [(update model :selected - #(if (contains? % cursor) - (disj % cursor) - (conj % cursor))) - nil] + ;; Toggle selection + (ev/key= event \space) + {:model (update model :selected + #(if (contains? % cursor) + (disj % cursor) + (conj % cursor)))} - ;; Submit - (tui/key= msg :enter) - [(assoc model :submitted true) tui/quit] + ;; Submit + (ev/key= event :enter) + {:model (assoc model :submitted true) + :events [(ev/quit)]} - :else - [model nil])) + :else + {:model model}))) ;; === View === (defn view [{:keys [cursor items selected submitted]} _size] @@ -83,7 +85,7 @@ (defn -main [& _args] (println "Starting list selection...") (let [{:keys [items selected submitted]} (tui/run {:init initial-model - :update update-model + :update update-fn :view view})] (when submitted (println) diff --git a/examples/spinner.clj b/examples/spinner.clj index f8ba590..3aab2ec 100644 --- a/examples/spinner.clj +++ b/examples/spinner.clj @@ -1,7 +1,8 @@ (ns examples.spinner "Spinner example - demonstrates animated loading states. Mirrors bubbletea's spinner example." - (:require [tui.core :as tui])) + (:require [tui.core :as tui] + [tui.events :as ev])) ;; === Spinner Frames === (def spinner-styles @@ -21,42 +22,43 @@ :style :dots :loading true :message "Loading..." - :styles (keys spinner-styles) + :styles (vec (keys spinner-styles)) :style-idx 0}) ;; === Update === -(defn update-model [{:keys [styles style-idx] :as model} msg] - (cond - ;; Quit - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] +(defn update-fn [{:keys [model event]}] + (let [{:keys [styles style-idx]} model] + (cond + ;; Quit + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} - ;; Spinner frame - advance animation - (= msg :spinner-frame) - (if (:loading model) - [(update model :frame inc) (tui/after 80 :spinner-frame)] - [model nil]) + ;; Spinner frame - advance animation + (= (:type event) :spinner-frame) + (if (:loading model) + {:model (update model :frame inc) + :events [(ev/delayed-event 80 {:type :spinner-frame})]} + {:model model}) - ;; Space - simulate completion - (tui/key= msg " ") - [(assoc model :loading false :message "Done!") nil] + ;; Space - simulate completion + (ev/key= event \space) + {:model (assoc model :loading false :message "Done!")} - ;; Tab - change spinner style - (tui/key= msg :tab) - (let [new-idx (mod (inc style-idx) (count styles))] - [(assoc model - :style-idx new-idx - :style (nth styles new-idx)) - nil]) + ;; Tab - change spinner style + (ev/key= event :tab) + (let [new-idx (mod (inc style-idx) (count styles))] + {:model (assoc model + :style-idx new-idx + :style (nth styles new-idx))}) - ;; r - restart - (tui/key= msg "r") - [(assoc model :loading true :frame 0 :message "Loading...") - (tui/after 80 :spinner-frame)] + ;; r - restart + (ev/key= event \r) + {:model (assoc model :loading true :frame 0 :message "Loading...") + :events [(ev/delayed-event 80 {:type :spinner-frame})]} - :else - [model nil])) + :else + {:model model}))) ;; === View === (defn spinner-view [{:keys [frame style]}] @@ -84,7 +86,7 @@ (defn -main [& _args] (println "Starting spinner...") (tui/run {:init initial-model - :update update-model + :update update-fn :view view - :init-cmd (tui/after 80 :spinner-frame)}) + :init-events [(ev/delayed-event 80 {:type :spinner-frame})]}) (println "Spinner demo finished.")) diff --git a/examples/timer.clj b/examples/timer.clj index 58164e9..ea142f3 100644 --- a/examples/timer.clj +++ b/examples/timer.clj @@ -1,7 +1,8 @@ (ns examples.timer "Countdown timer example - demonstrates async commands. Mirrors bubbletea's stopwatch/timer examples." - (:require [tui.core :as tui])) + (:require [tui.core :as tui] + [tui.events :as ev])) ;; === Model === (def initial-model @@ -10,37 +11,38 @@ :done false}) ;; === Update === -(defn update-model [model msg] +(defn update-fn [{:keys [model event]}] (cond ;; Quit - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} ;; Timer tick - decrement timer - (= msg :timer-tick) + (= (:type event) :timer-tick) (if (:running model) (let [new-seconds (dec (:seconds model))] (if (<= new-seconds 0) ;; Timer done - [(assoc model :seconds 0 :done true :running false) nil] + {:model (assoc model :seconds 0 :done true :running false)} ;; Continue countdown - [(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) - [model nil]) + {:model (assoc model :seconds new-seconds) + :events [(ev/delayed-event 1000 {:type :timer-tick})]})) + {:model model}) ;; Space - pause/resume - (tui/key= msg " ") + (ev/key= event \space) (let [new-running (not (:running model))] - [(assoc model :running new-running) - (when new-running (tui/after 1000 :timer-tick))]) + {:model (assoc model :running new-running) + :events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])}) ;; r - reset - (tui/key= msg "r") - [(assoc model :seconds 10 :done false :running true) - (tui/after 1000 :timer-tick)] + (ev/key= event \r) + {:model (assoc model :seconds 10 :done false :running true) + :events [(ev/delayed-event 1000 {:type :timer-tick})]} :else - [model nil])) + {:model model})) ;; === View === (defn format-time [seconds] @@ -74,8 +76,8 @@ (defn -main [& _args] (println "Starting timer...") (let [final-model (tui/run {:init initial-model - :update update-model + :update update-fn :view view - :init-cmd (tui/after 1000 :timer-tick)})] + :init-events [(ev/delayed-event 1000 {:type :timer-tick})]})] (when (:done final-model) (println "Timer completed!")))) diff --git a/examples/views.clj b/examples/views.clj index c8265bc..c88ab99 100644 --- a/examples/views.clj +++ b/examples/views.clj @@ -1,7 +1,8 @@ (ns examples.views "Multiple views example - demonstrates state machine pattern. Mirrors bubbletea's views example." - (:require [tui.core :as tui])) + (:require [tui.core :as tui] + [tui.events :as ev])) ;; === Model === (def initial-model @@ -14,58 +15,58 @@ :selected nil}) ;; === Update === -(defn update-model [{:keys [view cursor items] :as model} msg] - (case view - ;; Menu view - :menu - (cond - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] +(defn update-fn [{:keys [model event]}] + (let [{:keys [view cursor items]} model] + (case view + ;; Menu view + :menu + (cond + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} - (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 %)))} - (tui/key= msg :enter) - [(assoc model - :view :detail - :selected (nth items cursor)) - nil] + (ev/key= event :enter) + {:model (assoc model + :view :detail + :selected (nth items cursor))} - :else - [model nil]) + :else + {:model model}) - ;; Detail view - :detail - (cond - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [(assoc model :view :confirm) nil] + ;; Detail view + :detail + (cond + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model (assoc model :view :confirm)} - (or (tui/key= msg :escape) - (tui/key= msg "b")) - [(assoc model :view :menu :selected nil) nil] + (or (ev/key= event :escape) + (ev/key= event \b)) + {:model (assoc model :view :menu :selected nil)} - :else - [model nil]) + :else + {:model model}) - ;; Confirm quit dialog - :confirm - (cond - (tui/key= msg "y") - [model tui/quit] + ;; Confirm quit dialog + :confirm + (cond + (ev/key= event \y) + {:model model :events [(ev/quit)]} - (or (tui/key= msg "n") - (tui/key= msg :escape)) - [(assoc model :view :detail) nil] + (or (ev/key= event \n) + (ev/key= event :escape)) + {:model (assoc model :view :detail)} - :else - [model nil]))) + :else + {:model model})))) ;; === Views === (defn menu-view [{:keys [cursor items]}] @@ -112,6 +113,6 @@ (defn -main [& _args] (println "Starting views demo...") (tui/run {:init initial-model - :update update-model + :update update-fn :view view}) (println "Views demo finished.")) diff --git a/src/tui/ansi.clj b/src/tui/ansi.clj index 7fa02e0..52a0cbe 100644 --- a/src/tui/ansi.clj +++ b/src/tui/ansi.clj @@ -236,3 +236,13 @@ true active-styles))))))))] (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) " ")))))) diff --git a/src/tui/core.clj b/src/tui/core.clj index 8f6f71b..558b233 100644 --- a/src/tui/core.clj +++ b/src/tui/core.clj @@ -1,68 +1,29 @@ (ns tui.core "Core TUI framework - Elm architecture runtime. - ## New API (Recommended) - Update function signature: (fn [{:keys [model event]}] {:model new-model :events [...]}) ; :events is optional - 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." + Events are maps with :type discriminator. See tui.events namespace." (:require [tui.terminal :as term] [tui.input :as input] [tui.render :as render] - [tui.ansi :as ansi] + [tui.events :as ev] [clojure.core.async :as async :refer [go go-loop 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 === (defn- start-input-loop! "Start thread that reads input and puts events on channel. @@ -198,61 +124,24 @@ (Thread/sleep 10)) (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 === (defn run "Run a TUI application. - ## New API (Recommended) - Options: - - :init - Initial model (required) - - :update - (fn [{:keys [model event]}] {:model m :events [...]}) (required) - - :view - (fn [model size] hiccup) where size is {:width w :height h} (required) - - :fps - Target frames per second (default 60) - - :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 + - :init - Initial model (required) + - :update - (fn [{:keys [model event]}] {:model m :events [...]}) (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) + - :alt-screen - Use alternate screen buffer (default true) 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}}] (let [msg-chan (chan 256) running? (atom true) - frame-time (/ 1000 fps) - ;; Start in auto-detect mode, will switch to legacy if needed - legacy-mode? (atom false)] + frame-time (/ 1000 fps)] ;; Setup terminal (term/raw-mode!) @@ -264,12 +153,10 @@ ;; Start input loop (start-input-loop! msg-chan running?) - ;; Execute initial events/command + ;; Execute initial events (when init-events (doseq [event init-events] (execute-event! event msg-chan))) - (when init-cmd - (execute-cmd! init-cmd msg-chan)) ;; Initial render (let [size (term/get-terminal-size) @@ -291,27 +178,22 @@ (recur model (System/currentTimeMillis)) ;; Check for quit - (if (or (= event {:type :quit}) - (= event [:quit])) ; legacy + (if (= (:type event) :quit) ;; Quit - return final model model ;; Update model - (let [result (call-update update model event @legacy-mode?) - _ (when (:switch-to-legacy result) - (reset! legacy-mode? true)) + (let [result (update {:model model :event event}) new-model (:model result) size (term/get-terminal-size) ctx {:available-height (:height size) :available-width (:width size)} now (System/currentTimeMillis)] - ;; Execute events (new API) or command (legacy) - (if-let [events (:events result)] + ;; Execute events + (when-let [events (:events result)] (doseq [e events] - (execute-event! e msg-chan)) - (when-let [cmd (:legacy-cmd result)] - (execute-cmd! cmd msg-chan))) + (execute-event! e msg-chan))) ;; Render with context for flex layouts (term/render! (render/render (view new-model size) ctx)) @@ -335,30 +217,11 @@ (defapp my-app :init {:count 0} - :update (fn [ctx] ...) + :update (fn [{:keys [model event]}] ...) :view (fn [model size] ...))" - [name & {:keys [init update view init-cmd init-events]}] + [name & {:keys [init update view init-events]}] `(def ~name {:init ~init :update ~update :view ~view - :init-cmd ~init-cmd :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) diff --git a/src/tui/events.clj b/src/tui/events.clj index 7d9d4ae..9400a45 100644 --- a/src/tui/events.clj +++ b/src/tui/events.clj @@ -15,7 +15,7 @@ {:type :key, :key :enter} ; special key {:type :key, :key \\c, :modifiers #{:ctrl}} ; with modifiers {:type :quit} ; quit app - {:type :delay, :ms 2000, :event {...}} ; delayed event + {:type :delayed-event, :ms 2000, :event {...}} ; delayed event ## Update Function Contract @@ -126,18 +126,21 @@ [] {:type :quit}) -(defn delay +(defn delayed-event "Create an event that dispatches another event after a delay. - The nested event is dispatched after the specified milliseconds elapse. - Useful for transient messages, animations, debouncing, or timeouts. + Like JavaScript's setTimeout - schedules an event to be dispatched + 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: ms - Delay in milliseconds before dispatching event - Event map to dispatch after the delay Returns: - {:type :delay, :ms , :event } + {:type :delayed-event, :ms , :event } Examples: ;; Show a message that auto-clears after 3 seconds @@ -145,7 +148,7 @@ (case (:type event) :show-message {:model (assoc model :message (:text event)) - :events [(delay 3000 {:type :clear-message})]} + :events [(delayed-event 3000 {:type :clear-message})]} :clear-message {:model (dissoc model :message)} @@ -158,13 +161,13 @@ {:model (-> model (update :buffer conj event) (assoc :dirty true)) - :events [(delay 5000 {:type :auto-save})]} + :events [(delayed-event 5000 {:type :auto-save})]} {:model model})) ;; Simple animation frame - {:events [(delay 16 {:type :animation-tick})]}" + {:events [(delayed-event 16 {:type :animation-tick})]}" [ms event] - {:type :delay, :ms ms, :event event}) + {:type :delayed-event, :ms ms, :event event}) (defn shell "Create an event that runs a shell command asynchronously. @@ -274,7 +277,7 @@ ;; Show message, wait, then clear (sequential {:type :show-message, :text \"Saved!\"} - (delay 2000 {:type :clear-message})) + (delayed-event 2000 {:type :clear-message})) Note: For complex workflows, consider handling each step explicitly diff --git a/src/tui/render.clj b/src/tui/render.clj index 53d6cf2..3de784b 100644 --- a/src/tui/render.clj +++ b/src/tui/render.clj @@ -286,10 +286,10 @@ box-width (or target-width (+ (max inner-width title-width) 2)) content-width (- box-width 2) - ;; Pad lines + ;; Pad lines (and truncate if too long) padded-lines (for [line lines] (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 " ")))) ;; Add vertical padding @@ -322,7 +322,7 @@ (:br chars)) body-lines (for [line all-lines] (str (:v chars) - (ansi/pad-right line content-width) + (ansi/fit-width line content-width) (:v chars)))] (str/join "\n" (concat [top-line] body-lines [bottom-line])))) diff --git a/test/tui/api_test.clj b/test/tui/api_test.clj index c8b6780..831c7f4 100644 --- a/test/tui/api_test.clj +++ b/test/tui/api_test.clj @@ -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}]))))) diff --git a/test/tui/core_test.clj b/test/tui/core_test.clj index cde3988..13a326a 100644 --- a/test/tui/core_test.clj +++ b/test/tui/core_test.clj @@ -1,90 +1,88 @@ (ns tui.core-test "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]] [clojure.core.async :as async :refer [chan >!! view -> render produces valid output" @@ -98,10 +96,12 @@ (is (clojure.string/includes? rendered "Counter")) (is (clojure.string/includes? rendered "Count: 5"))))) -;; === New API Update Function Tests === +;; ============================================================================= +;; UPDATE FUNCTION CONTRACT TESTS +;; ============================================================================= -(deftest new-update-function-contract-test - (testing "new update function returns {:model ... :events ...}" +(deftest update-function-contract-test + (testing "update function returns {:model ... :events ...}" (let [update-fn (fn [{:keys [model event]}] (cond (ev/key= event \q) {:model model :events [(ev/quit)]} @@ -124,33 +124,9 @@ (is (= {:n 0} model)) (is (nil? events)))))) -;; === Legacy Update Function 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 === +;; ============================================================================= +;; EVENT EXECUTION TESTS +;; ============================================================================= (deftest execute-quit-event-test (testing "quit event puts {:type :quit} on channel" @@ -162,10 +138,10 @@ (is (= {:type :quit} result))) (close! msg-chan)))) -(deftest execute-delay-event-test - (testing "delay event sends message after delay" +(deftest execute-delayed-event-test + (testing "delayed-event sends message after delay" (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) ;; Should not receive immediately (let [immediate (alt!! @@ -218,94 +194,17 @@ (is (= :timeout result))) (close! msg-chan)))) -;; === Legacy Command Execution 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 === +;; ============================================================================= +;; DEFAPP MACRO TESTS +;; ============================================================================= (deftest defapp-macro-test - (testing "defapp creates app map (legacy)" - (tui/defapp test-app-legacy - :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 + (testing "defapp creates app map" + (tui/defapp test-app :init {:count 0} :update (fn [{:keys [model event]}] {:model model}) :view (fn [m size] [:text "test"])) - (is (map? test-app-new)) - (is (= {:count 0} (:init test-app-new))) - (is (fn? (:update test-app-new))) - (is (fn? (:view test-app-new))))) + (is (map? test-app)) + (is (= {:count 0} (:init test-app))) + (is (fn? (:update test-app))) + (is (fn? (:view test-app))))) diff --git a/test/tui/edge_cases_test.clj b/test/tui/edge_cases_test.clj index f7e7698..305be7b 100644 --- a/test/tui/edge_cases_test.clj +++ b/test/tui/edge_cases_test.clj @@ -4,6 +4,7 @@ (: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] [tui.ansi :as ansi])) @@ -145,98 +146,65 @@ ;; ============================================================================= (deftest key-match-edge-cases-test - (testing "empty string pattern" - (is (not (input/key-match? [:key {:char \a}] "")))) + (testing "nil event returns false" + (is (not (ev/key= nil \q))) + (is (not (ev/key= nil :enter)))) - (testing "multi-char string pattern only matches first char" - ;; The current implementation only looks at first char - (is (input/key-match? [:key {:char \q}] "quit"))) + (testing "non-key event returns false" + (is (not (ev/key= {:type :timer-tick} \q))) + (is (not (ev/key= {:type :http-result :status 200} :enter)))) - (testing "nil message returns false" - (is (not (input/key-match? nil "q"))) - (is (not (input/key-match? nil :enter)))) - - (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}]))))) + (testing "key event with missing key field" + (is (not (ev/key= {:type :key} \q))) + (is (not (ev/key= {:type :key :modifiers #{:ctrl}} \c #{:ctrl}))))) ;; ============================================================================= -;; COMMAND EDGE CASES +;; EVENT EDGE CASES ;; ============================================================================= (deftest batch-edge-cases-test (testing "batch with all nils" - (is (= [:batch] (tui/batch nil nil nil)))) + (is (nil? (ev/batch nil nil nil)))) - (testing "batch with single command" - (is (= [:batch tui/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)))))) (testing "batch with no arguments" - (is (= [:batch] (tui/batch)))) + (is (nil? (ev/batch)))) - (testing "batch with many commands" - (let [cmd (tui/batch (tui/after 1 :t1) (tui/after 2 :t2) (tui/after 3 :t3) (tui/after 4 :t4) (tui/after 5 :t5))] - (is (= 6 (count cmd))) ; :batch + 5 commands - (is (= :batch (first cmd)))))) + (testing "batch with many events" + (let [event (ev/batch {:type :t1} {:type :t2} {:type :t3} {:type :t4} {:type :t5})] + (is (= 5 (count (:events event)))) + (is (= :batch (:type event)))))) -(deftest sequentially-edge-cases-test - (testing "sequentially with all nils" - (is (= [:seq] (tui/sequentially nil nil nil)))) +(deftest sequential-edge-cases-test + (testing "sequential with all nils" + (is (nil? (ev/sequential nil nil nil)))) - (testing "sequentially with single command" - (is (= [:seq tui/quit] (tui/sequentially tui/quit)))) + (testing "sequential with single event" + (let [event (ev/sequential {:type :msg1})] + (is (= :sequential (:type event))) + (is (= 1 (count (:events event)))))) - (testing "sequentially with no arguments" - (is (= [:seq] (tui/sequentially))))) + (testing "sequential with no arguments" + (is (nil? (ev/sequential))))) -(deftest after-edge-cases-test - (testing "after with zero delay" - (let [cmd (tui/after 0 :immediate)] - (is (fn? cmd)) - ;; Zero delay executes immediately - (is (= :immediate (cmd))))) +(deftest delayed-event-edge-cases-test + (testing "delayed-event with zero delay" + (let [event (ev/delayed-event 0 {:type :immediate})] + (is (= :delayed-event (:type event))) + (is (= 0 (:ms event))))) - (testing "after with various delays creates function" - ;; Don't invoke - just verify the function is created correctly - (is (fn? (tui/after 1 :t1))) - (is (fn? (tui/after 1000 :t2))) - (is (fn? (tui/after 999999999 :t3)))) + (testing "delayed-event with various delays" + (is (= 1 (:ms (ev/delayed-event 1 {:type :t1})))) + (is (= 1000 (:ms (ev/delayed-event 1000 {:type :t2})))) + (is (= 999999999 (:ms (ev/delayed-event 999999999 {:type :t3}))))) - (testing "after with complex message" - (let [cmd (tui/after 0 [:tick {:id 1 :data [1 2 3]}])] - (is (= [:tick {:id 1 :data [1 2 3]}] (cmd)))))) - -(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)))))) + (testing "delayed-event with complex message" + (let [event (ev/delayed-event 0 {:type :tick :id 1 :data [1 2 3]})] + (is (= {:type :tick :id 1 :data [1 2 3]} (:event event)))))) ;; ============================================================================= ;; ANSI EDGE CASES @@ -319,27 +287,24 @@ ;; UPDATE FUNCTION EDGE CASES ;; ============================================================================= -(deftest update-with-unknown-messages-test - (testing "update function handles unknown messages gracefully" - (let [update-fn (fn [model msg] +(deftest update-with-unknown-events-test + (testing "update function handles unknown events gracefully" + (let [update-fn (fn [{:keys [model event]}] (cond - (tui/key= msg "q") [model tui/quit] - :else [model nil]))] + (ev/key= event \q) {:model model :events [(ev/quit)]} + :else {:model model}))] ;; Unknown key - (let [[m cmd] (update-fn {:n 0} [:key {:char \x}])] - (is (= {:n 0} m)) - (is (nil? cmd))) + (let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :key :key \x}})] + (is (= {:n 0} model))) - ;; Unknown message type - (let [[m cmd] (update-fn {:n 0} [:unknown :message])] - (is (= {:n 0} m)) - (is (nil? cmd))) + ;; Unknown event type + (let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :unknown :message "test"}})] + (is (= {:n 0} model))) - ;; Empty message - (let [[m cmd] (update-fn {:n 0} [])] - (is (= {:n 0} m)) - (is (nil? cmd)))))) + ;; Event with no type + (let [{:keys [model]} (update-fn {:model {:n 0} :event {}})] + (is (= {:n 0} model)))))) (deftest model-with-complex-state-test (testing "model with nested data structures" @@ -348,23 +313,22 @@ :nested {:deep {:value 42}} :selected #{} :history []} - update-fn (fn [model msg] - (if (tui/key= msg :up) - [(-> model - (update :count inc) - (update :history conj (:count model))) - nil] - [model nil]))] + update-fn (fn [{:keys [model event]}] + (if (ev/key= event :up) + {:model (-> model + (update :count inc) + (update :history conj (:count model)))} + {:model model}))] - (let [[m1 _] (update-fn complex-model [:key :up]) - [m2 _] (update-fn m1 [:key :up])] - (is (= 1 (:count m1))) - (is (= [0] (:history m1))) - (is (= 2 (:count m2))) - (is (= [0 1] (:history m2))) + (let [r1 (update-fn {:model complex-model :event {:type :key :key :up}}) + r2 (update-fn {:model (:model r1) :event {:type :key :key :up}})] + (is (= 1 (:count (:model r1)))) + (is (= [0] (:history (:model r1)))) + (is (= 2 (:count (:model r2)))) + (is (= [0 1] (:history (:model r2)))) ;; Other fields unchanged - (is (= ["a" "b" "c"] (:items m2))) - (is (= 42 (get-in m2 [:nested :deep :value]))))))) + (is (= ["a" "b" "c"] (:items (:model r2)))) + (is (= 42 (get-in (:model r2) [:nested :deep :value]))))))) ;; ============================================================================= ;; VIEW FUNCTION EDGE CASES diff --git a/test/tui/events_test.clj b/test/tui/events_test.clj index 8d6f9fd..f2ca889 100644 --- a/test/tui/events_test.clj +++ b/test/tui/events_test.clj @@ -9,14 +9,14 @@ (testing "quit returns quit event" (is (= {:type :quit} (ev/quit))))) -(deftest delay-test - (testing "delay creates delay event" - (is (= {:type :delay :ms 1000 :event {:type :tick}} - (ev/delay 1000 {:type :tick})))) +(deftest delayed-event-test + (testing "delayed-event creates delayed-event event" + (is (= {:type :delayed-event :ms 1000 :event {:type :tick}} + (ev/delayed-event 1000 {:type :tick})))) - (testing "delay with different ms values" - (is (= 0 (:ms (ev/delay 0 {:type :x})))) - (is (= 5000 (:ms (ev/delay 5000 {:type :x})))))) + (testing "delayed-event with different ms values" + (is (= 0 (:ms (ev/delayed-event 0 {:type :x})))) + (is (= 5000 (:ms (ev/delayed-event 5000 {:type :x})))))) (deftest shell-test (testing "shell creates shell event with vector cmd" @@ -124,11 +124,11 @@ (testing "can compose multiple event constructors" (let [result (ev/batch (ev/shell ["git" "status"] {:type :status}) - (ev/delay 1000 {:type :refresh}))] + (ev/delayed-event 1000 {:type :refresh}))] (is (= :batch (:type result))) (is (= 2 (count (: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" (let [result (ev/sequential diff --git a/test/tui/examples_test.clj b/test/tui/examples_test.clj index ce0acc1..bf0b376 100644 --- a/test/tui/examples_test.clj +++ b/test/tui/examples_test.clj @@ -4,6 +4,7 @@ (:require [clojure.test :refer [deftest testing is are]] [clojure.string :as str] [tui.core :as tui] + [tui.events :as ev] [tui.render :as render])) ;; ============================================================================= @@ -18,43 +19,43 @@ (deftest counter-update-all-keys-test (testing "counter responds to all documented keys" - (let [update-fn (fn [model msg] + (let [update-fn (fn [{:keys [model event]}] (cond - (or (tui/key= msg "q") - (tui/key= msg [:ctrl \c])) - [model tui/quit] + (or (ev/key= event \q) + (ev/key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} - (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}))] ;; All increment keys - (are [msg] (= 1 (:count (first (update-fn {:count 0} msg)))) - [:key :up] - [:key {:char \k}]) + (are [event] (= 1 (:count (:model (update-fn {:model {:count 0} :event event})))) + {:type :key :key :up} + {:type :key :key \k}) ;; All decrement keys - (are [msg] (= -1 (:count (first (update-fn {:count 0} msg)))) - [:key :down] - [:key {:char \j}]) + (are [event] (= -1 (:count (:model (update-fn {:model {:count 0} :event event})))) + {:type :key :key :down} + {:type :key :key \j}) ;; All quit keys - (are [msg] (= tui/quit (second (update-fn {:count 0} msg))) - [:key {:char \q}] - [:key {:ctrl true :char \c}]) + (are [event] (= [(ev/quit)] (:events (update-fn {:model {:count 0} :event event}))) + {:type :key :key \q} + {:type :key :key \c :modifiers #{:ctrl}}) ;; 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 (testing "counter view shows correct colors based on count" @@ -125,66 +126,72 @@ (deftest timer-tick-countdown-test (testing "timer tick decrements and reaches zero" - (let [update-fn (fn [{:keys [seconds running] :as model} msg] - (cond - (= msg :timer-tick) - (if running - (let [new-seconds (dec seconds)] + (let [update-fn (fn [{:keys [model event]}] + (if (= (: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]) - :else [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}) + {:model model}))] ;; Normal tick - (let [[m1 c1] (update-fn {:seconds 10 :running true :done false} :timer-tick)] - (is (= 9 (:seconds m1))) - (is (fn? c1))) + (let [result (update-fn {:model {:seconds 10 :running true :done false} + :event {:type :timer-tick}})] + (is (= 9 (:seconds (:model result)))) + (is (= 1 (count (:events result))))) ;; Tick to zero - (let [[m1 c1] (update-fn {:seconds 1 :running true :done false} :timer-tick)] - (is (= 0 (:seconds m1))) - (is (true? (:done m1))) - (is (false? (:running m1))) - (is (nil? c1))) + (let [result (update-fn {:model {:seconds 1 :running true :done false} + :event {:type :timer-tick}})] + (is (= 0 (:seconds (:model result)))) + (is (true? (:done (:model result)))) + (is (false? (:running (:model result)))) + (is (nil? (:events result)))) ;; Tick when paused does nothing - (let [[m1 c1] (update-fn {:seconds 5 :running false :done false} :timer-tick)] - (is (= 5 (:seconds m1))) - (is (nil? c1)))))) + (let [result (update-fn {:model {:seconds 5 :running false :done false} + :event {:type :timer-tick}})] + (is (= 5 (:seconds (:model result)))) + (is (nil? (:events result))))))) (deftest timer-pause-resume-test (testing "timer pause/resume with space key" - (let [update-fn (fn [{:keys [running] :as model} msg] - (if (tui/key= msg " ") - (let [new-running (not running)] - [(assoc model :running new-running) - (when new-running (tui/after 1000 :timer-tick))]) - [model nil]))] + (let [update-fn (fn [{:keys [model event]}] + (if (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})])}) + {:model model}))] ;; Pause (running -> not running) - (let [[m1 c1] (update-fn {:seconds 5 :running true} [:key {:char \space}])] - (is (false? (:running m1))) - (is (nil? c1))) + (let [result (update-fn {:model {:seconds 5 :running true} + :event {:type :key :key \space}})] + (is (false? (:running (:model result)))) + (is (nil? (:events result)))) ;; Resume (not running -> running) - (let [[m1 c1] (update-fn {:seconds 5 :running false} [:key {:char \space}])] - (is (true? (:running m1))) - (is (fn? c1)))))) + (let [result (update-fn {:model {:seconds 5 :running false} + :event {:type :key :key \space}})] + (is (true? (:running (:model result)))) + (is (= 1 (count (:events result)))))))) (deftest timer-reset-test (testing "timer reset restores initial state" - (let [update-fn (fn [model msg] - (if (tui/key= msg "r") - [(assoc model :seconds 10 :done false :running true) - (tui/after 1000 :timer-tick)] - [model nil]))] + (let [update-fn (fn [{:keys [model event]}] + (if (ev/key= event \r) + {:model (assoc model :seconds 10 :done false :running true) + :events [(ev/delayed-event 1000 {:type :timer-tick})]} + {:model model}))] - (let [[m1 c1] (update-fn {:seconds 0 :done true :running false} [:key {:char \r}])] - (is (= 10 (:seconds m1))) - (is (false? (:done m1))) - (is (true? (:running m1))) - (is (fn? c1)))))) + (let [result (update-fn {:model {:seconds 0 :done true :running false} + :event {:type :key :key \r}})] + (is (= 10 (:seconds (:model result)))) + (is (false? (:done (:model result)))) + (is (true? (:running (:model result)))) + (is (= 1 (count (:events result)))))))) (deftest timer-view-color-logic-test (testing "timer view shows correct colors" @@ -239,68 +246,74 @@ (deftest spinner-tick-advances-frame-test (testing "spinner tick advances frame when loading" - (let [update-fn (fn [model msg] - (if (= msg :spinner-frame) + (let [update-fn (fn [{:keys [model event]}] + (if (= (:type event) :spinner-frame) (if (:loading model) - [(update model :frame inc) (tui/after 80 :spinner-frame)] - [model nil]) - [model nil]))] + {:model (update model :frame inc) + :events [(ev/delayed-event 80 {:type :spinner-frame})]} + {:model model}) + {:model model}))] ;; Tick advances frame when loading - (let [[m1 c1] (update-fn {:frame 0 :loading true} :spinner-frame)] - (is (= 1 (:frame m1))) - (is (fn? c1))) + (let [result (update-fn {:model {:frame 0 :loading true} + :event {:type :spinner-frame}})] + (is (= 1 (:frame (:model result)))) + (is (= 1 (count (:events result))))) ;; Tick does nothing when not loading - (let [[m1 c1] (update-fn {:frame 5 :loading false} :spinner-frame)] - (is (= 5 (:frame m1))) - (is (nil? c1)))))) + (let [result (update-fn {:model {:frame 5 :loading false} + :event {:type :spinner-frame}})] + (is (= 5 (:frame (:model result)))) + (is (nil? (:events result))))))) (deftest spinner-style-switching-test (testing "spinner tab key cycles through styles" - (let [styles (keys spinner-styles) - update-fn (fn [{:keys [style-idx] :as model} msg] - (if (tui/key= msg :tab) - (let [new-idx (mod (inc style-idx) (count styles))] - [(assoc model - :style-idx new-idx - :style (nth styles new-idx)) - nil]) - [model nil]))] + (let [styles (vec (keys spinner-styles)) + update-fn (fn [{:keys [model event]}] + (if (ev/key= event :tab) + (let [new-idx (mod (inc (:style-idx model)) (count styles))] + {:model (assoc model + :style-idx new-idx + :style (nth styles new-idx))}) + {:model model}))] ;; Tab advances style - (let [[m1 _] (update-fn {:style-idx 0 :style (first styles)} [:key :tab])] - (is (= 1 (:style-idx m1)))) + (let [result (update-fn {:model {:style-idx 0 :style (first styles)} + :event {:type :key :key :tab}})] + (is (= 1 (:style-idx (:model result))))) ;; Tab wraps around (let [last-idx (dec (count styles)) - [m1 _] (update-fn {:style-idx last-idx :style (last styles)} [:key :tab])] - (is (= 0 (:style-idx m1))))))) + result (update-fn {:model {:style-idx last-idx :style (last styles)} + :event {:type :key :key :tab}})] + (is (= 0 (:style-idx (:model result)))))))) (deftest spinner-completion-test (testing "spinner space key completes loading" - (let [update-fn (fn [model msg] - (if (tui/key= msg " ") - [(assoc model :loading false :message "Done!") nil] - [model nil]))] + (let [update-fn (fn [{:keys [model event]}] + (if (ev/key= event \space) + {:model (assoc model :loading false :message "Done!")} + {:model model}))] - (let [[m1 _] (update-fn {:loading true :message "Loading..."} [:key {:char \space}])] - (is (false? (:loading m1))) - (is (= "Done!" (:message m1))))))) + (let [result (update-fn {:model {:loading true :message "Loading..."} + :event {:type :key :key \space}})] + (is (false? (:loading (:model result)))) + (is (= "Done!" (:message (:model result)))))))) (deftest spinner-restart-test (testing "spinner r key restarts animation" - (let [update-fn (fn [model msg] - (if (tui/key= msg "r") - [(assoc model :loading true :frame 0 :message "Loading...") - (tui/after 80 :spinner-frame)] - [model nil]))] + (let [update-fn (fn [{:keys [model event]}] + (if (ev/key= event \r) + {:model (assoc model :loading true :frame 0 :message "Loading...") + :events [(ev/delayed-event 80 {:type :spinner-frame})]} + {:model model}))] - (let [[m1 c1] (update-fn {:loading false :frame 100 :message "Done!"} [:key {:char \r}])] - (is (true? (:loading m1))) - (is (= 0 (:frame m1))) - (is (= "Loading..." (:message m1))) - (is (fn? c1)))))) + (let [result (update-fn {:model {:loading false :frame 100 :message "Done!"} + :event {:type :key :key \r}})] + (is (true? (:loading (:model result)))) + (is (= 0 (:frame (:model result)))) + (is (= "Loading..." (:message (:model result)))) + (is (= 1 (count (:events result)))))))) ;; ============================================================================= ;; LIST SELECTION EXAMPLE TESTS @@ -320,25 +333,25 @@ (deftest list-selection-cursor-navigation-test (testing "cursor navigation respects bounds" (let [items ["A" "B" "C" "D" "E"] - 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]))] + :else {:model model}))] ;; Move down through list (let [m0 {:cursor 0} - [m1 _] (update-fn m0 [:key :down]) - [m2 _] (update-fn m1 [:key :down]) - [m3 _] (update-fn m2 [:key :down]) - [m4 _] (update-fn m3 [:key :down]) - [m5 _] (update-fn m4 [:key :down])] ; Should stop at 4 + m1 (:model (update-fn {:model m0 :event {:type :key :key :down}})) + 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 (= 1 (:cursor m1))) (is (= 2 (:cursor m2))) (is (= 3 (:cursor m3))) @@ -346,47 +359,49 @@ (is (= 4 (:cursor m5)))) ; Clamped at max ;; Move up from top - (let [[m1 _] (update-fn {:cursor 0} [:key :up])] - (is (= 0 (:cursor m1))))))) ; Clamped at 0 + (let [result (update-fn {:model {:cursor 0} :event {:type :key :key :up}})] + (is (= 0 (:cursor (:model result)))))))) ; Clamped at 0 (deftest list-selection-toggle-test (testing "space toggles selection" - (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}))] ;; Select item - (let [[m1 _] (update-fn {:cursor 0 :selected #{}} [:key {:char \space}])] - (is (= #{0} (:selected m1)))) + (let [result (update-fn {:model {:cursor 0 :selected #{}} + :event {:type :key :key \space}})] + (is (= #{0} (:selected (:model result))))) ;; Select multiple items (let [m0 {:cursor 0 :selected #{}} - [m1 _] (update-fn m0 [:key {:char \space}]) - m1' (assoc m1 :cursor 2) - [m2 _] (update-fn m1' [:key {:char \space}]) - m2' (assoc m2 :cursor 4) - [m3 _] (update-fn m2' [: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 4) :event {:type :key :key \space}}))] (is (= #{0 2 4} (:selected m3)))) ;; Deselect item - (let [[m1 _] (update-fn {:cursor 1 :selected #{1 2}} [:key {:char \space}])] - (is (= #{2} (:selected m1))))))) + (let [result (update-fn {:model {:cursor 1 :selected #{1 2}} + :event {:type :key :key \space}})] + (is (= #{2} (:selected (:model result)))))))) (deftest list-selection-submit-test (testing "enter submits selection" - (let [update-fn (fn [model msg] - (if (tui/key= msg :enter) - [(assoc model :submitted true) tui/quit] - [model nil]))] + (let [update-fn (fn [{:keys [model event]}] + (if (ev/key= event :enter) + {:model (assoc model :submitted true) + :events [(ev/quit)]} + {:model model}))] - (let [[m1 c1] (update-fn {:selected #{0 2} :submitted false} [:key :enter])] - (is (true? (:submitted m1))) - (is (= tui/quit c1)))))) + (let [result (update-fn {:model {:selected #{0 2} :submitted false} + :event {:type :key :key :enter}})] + (is (true? (:submitted (:model result)))) + (is (= [(ev/quit)] (:events result))))))) (deftest list-selection-view-item-count-test (testing "view shows correct item count" @@ -416,86 +431,93 @@ (deftest views-menu-navigation-test (testing "menu view cursor navigation" (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 - (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]))] + :else {:model model}))] ;; Navigate down - (let [[m1 _] (update-fn {:cursor 0} [:key {:char \j}])] - (is (= 1 (:cursor m1)))) + (let [result (update-fn {:model {:cursor 0} :event {:type :key :key \j}})] + (is (= 1 (:cursor (:model result))))) ;; Navigate up - (let [[m1 _] (update-fn {:cursor 2} [:key {:char \k}])] - (is (= 1 (:cursor m1))))))) + (let [result (update-fn {:model {:cursor 2} :event {:type :key :key \k}})] + (is (= 1 (:cursor (:model result)))))))) (deftest views-state-transitions-test (testing "all view state transitions" (let [items [{:name "Profile"} {:name "Settings"}] - update-fn (fn [{:keys [view cursor] :as model} msg] - (case view + update-fn (fn [{:keys [model event]}] + (case (:view model) :menu (cond - (tui/key= msg :enter) - [(assoc model :view :detail :selected (nth items cursor)) nil] - (tui/key= msg "q") - [model tui/quit] - :else [model nil]) + (ev/key= event :enter) + {:model (assoc model :view :detail :selected (nth items (:cursor model)))} + (ev/key= event \q) + {:model model :events [(ev/quit)]} + :else {:model model}) :detail (cond - (or (tui/key= msg :escape) - (tui/key= msg "b")) - [(assoc model :view :menu :selected nil) 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 :selected nil)} + (ev/key= event \q) + {:model (assoc model :view :confirm)} + :else {:model model}) :confirm (cond - (tui/key= msg "y") - [model tui/quit] - (or (tui/key= msg "n") - (tui/key= msg :escape)) - [(assoc model :view :detail) nil] - :else [model nil])))] + (ev/key= event \y) + {:model model :events [(ev/quit)]} + (or (ev/key= event \n) + (ev/key= event :escape)) + {:model (assoc model :view :detail)} + :else {:model model})))] ;; Menu -> Detail via enter - (let [[m1 _] (update-fn {:view :menu :cursor 0 :items items} [:key :enter])] - (is (= :detail (:view m1))) - (is (= "Profile" (:name (:selected m1))))) + (let [result (update-fn {:model {:view :menu :cursor 0 :items items} + :event {:type :key :key :enter}})] + (is (= :detail (:view (:model result)))) + (is (= "Profile" (:name (:selected (:model result)))))) ;; Detail -> Menu via escape - (let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key :escape])] - (is (= :menu (:view m1))) - (is (nil? (:selected m1)))) + (let [result (update-fn {:model {:view :detail :selected {:name "X"}} + :event {:type :key :key :escape}})] + (is (= :menu (:view (:model result)))) + (is (nil? (:selected (:model result))))) ;; Detail -> Menu via b - (let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key {:char \b}])] - (is (= :menu (:view m1)))) + (let [result (update-fn {:model {:view :detail :selected {:name "X"}} + :event {:type :key :key \b}})] + (is (= :menu (:view (:model result))))) ;; Detail -> Confirm via q - (let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])] - (is (= :confirm (:view m1)))) + (let [result (update-fn {:model {:view :detail} + :event {:type :key :key \q}})] + (is (= :confirm (:view (:model result))))) ;; Confirm -> Quit via y - (let [[_ c1] (update-fn {:view :confirm} [:key {:char \y}])] - (is (= tui/quit c1))) + (let [result (update-fn {:model {:view :confirm} + :event {:type :key :key \y}})] + (is (= [(ev/quit)] (:events result)))) ;; Confirm -> Detail via n - (let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])] - (is (= :detail (:view m1)))) + (let [result (update-fn {:model {:view :confirm} + :event {:type :key :key \n}})] + (is (= :detail (:view (:model result))))) ;; Confirm -> Detail via escape - (let [[m1 _] (update-fn {:view :confirm} [:key :escape])] - (is (= :detail (:view m1))))))) + (let [result (update-fn {:model {:view :confirm} + :event {:type :key :key :escape}})] + (is (= :detail (:view (:model result)))))))) ;; ============================================================================= ;; HTTP EXAMPLE TESTS @@ -514,66 +536,58 @@ (deftest http-state-machine-test (testing "http state transitions" - (let [update-fn (fn [{:keys [state url] :as model} msg] + (let [update-fn (fn [{:keys [model event]}] (cond ;; Start request - (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})]} ;; Reset - (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)} - ;; HTTP success - (= (first msg) :http-success) - [(assoc model :state :success :status (second msg)) nil] - - ;; HTTP error - (= (first msg) :http-error) - [(assoc model :state :error :error (second msg)) nil] + ;; HTTP result + (= (:type event) :http-result) + (if (get-in event [:result :success]) + {:model (assoc model :state :success :status 200)} + {:model (assoc model :state :error :error (get-in event [:result :err]))}) :else - [model nil]))] + {:model model}))] ;; Idle -> Loading via enter - (let [[m1 c1] (update-fn {:state :idle :url "http://test.com"} [:key :enter])] - (is (= :loading (:state m1))) - (is (fn? c1))) + (let [result (update-fn {:model {:state :idle :url "http://test.com"} + :event {:type :key :key :enter}})] + (is (= :loading (:state (:model result)))) + (is (= 1 (count (:events result))))) ;; Enter ignored when not idle - (let [[m1 c1] (update-fn {:state :loading} [:key :enter])] - (is (= :loading (:state m1))) - (is (nil? c1))) + (let [result (update-fn {:model {:state :loading} + :event {:type :key :key :enter}})] + (is (= :loading (:state (:model result)))) + (is (nil? (:events result)))) ;; Loading -> Success - (let [[m1 _] (update-fn {:state :loading} [:http-success 200])] - (is (= :success (:state m1))) - (is (= 200 (:status m1)))) + (let [result (update-fn {:model {:state :loading} + :event {:type :http-result :result {:success true :out "200"}}})] + (is (= :success (:state (:model result)))) + (is (= 200 (:status (:model result))))) ;; Loading -> Error - (let [[m1 _] (update-fn {:state :loading} [:http-error "Connection refused"])] - (is (= :error (:state m1))) - (is (= "Connection refused" (:error m1)))) + (let [result (update-fn {:model {:state :loading} + :event {:type :http-result :result {:success false :err "Connection refused"}}})] + (is (= :error (:state (:model result)))) + (is (= "Connection refused" (:error (:model result))))) ;; Reset from any state (doseq [state [:idle :loading :success :error]] - (let [[m1 _] (update-fn {:state state :status 200 :error "err"} [:key {:char \r}])] - (is (= :idle (:state m1))) - (is (nil? (:status m1))) - (is (nil? (:error m1)))))))) - -(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))))))) + (let [result (update-fn {:model {:state state :status 200 :error "err"} + :event {:type :key :key \r}})] + (is (= :idle (:state (:model result)))) + (is (nil? (:status (:model result)))) + (is (nil? (:error (:model result))))))))) (deftest http-view-states-test (testing "http view renders different states"