(ns tui.examples-test "Unit tests derived directly from example code patterns. Tests update functions, view functions, and helper functions from examples." (:require [clojure.test :refer [deftest testing is are]] [clojure.string :as str] [tui.core :as tui] [tui.events :as ev] [tui.render :as render])) ;; ============================================================================= ;; COUNTER EXAMPLE TESTS ;; ============================================================================= (deftest counter-initial-model-test (testing "counter initial model structure" (let [initial-model {:count 0}] (is (= 0 (:count initial-model))) (is (map? initial-model))))) (deftest counter-update-all-keys-test (testing "counter responds to all documented keys" (let [update-fn (fn [{:keys [model event]}] (cond (or (ev/key= event \q) (ev/key= event \c #{:ctrl})) {:model model :events [(ev/quit)]} (or (ev/key= event :up) (ev/key= event \k)) {:model (update model :count inc)} (or (ev/key= event :down) (ev/key= event \j)) {:model (update model :count dec)} (ev/key= event \r) {:model (assoc model :count 0)} :else {:model model}))] ;; All increment keys (are [event] (= 1 (:count (:model (update-fn {:model {:count 0} :event event})))) {:type :key :key :up} {:type :key :key \k}) ;; All decrement keys (are [event] (= -1 (:count (:model (update-fn {:model {:count 0} :event event})))) {:type :key :key :down} {:type :key :key \j}) ;; All quit keys (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 (: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" (let [get-color (fn [count] (cond (pos? count) :green (neg? count) :red :else :default))] (is (= :green (get-color 5))) (is (= :green (get-color 1))) (is (= :red (get-color -1))) (is (= :red (get-color -100))) (is (= :default (get-color 0)))))) (deftest counter-view-structure-test (testing "counter view produces valid hiccup" (let [view (fn [{:keys [count]}] [:col {:gap 1} [:box {:border :rounded :padding [0 1]} [:col [:text {:bold true} "Counter"] [:text ""] [:text {:fg (cond (pos? count) :green (neg? count) :red :else :default)} (str "Count: " count)]]] [:text {:fg :gray} "j/k or up/down: change value"]])] ;; View returns valid hiccup (let [result (view {:count 5})] (is (vector? result)) (is (= :col (first result)))) ;; View renders without error (is (string? (render/render (view {:count 5})))) (is (string? (render/render (view {:count -3})))) (is (string? (render/render (view {:count 0}))))))) ;; ============================================================================= ;; TIMER EXAMPLE TESTS ;; ============================================================================= (deftest timer-initial-model-test (testing "timer initial model structure" (let [initial-model {:seconds 10 :running true :done false}] (is (= 10 (:seconds initial-model))) (is (true? (:running initial-model))) (is (false? (:done initial-model)))))) (deftest timer-format-time-test (testing "format-time produces MM:SS format" (let [format-time (fn [seconds] (let [mins (quot seconds 60) secs (mod seconds 60)] (format "%02d:%02d" mins secs)))] (is (= "00:00" (format-time 0))) (is (= "00:01" (format-time 1))) (is (= "00:10" (format-time 10))) (is (= "00:59" (format-time 59))) (is (= "01:00" (format-time 60))) (is (= "01:30" (format-time 90))) (is (= "05:00" (format-time 300))) (is (= "10:00" (format-time 600))) (is (= "59:59" (format-time 3599)))))) (deftest timer-tick-countdown-test (testing "timer tick decrements and reaches zero" (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) {: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 [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 [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 [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 [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 [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 [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 [{: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 [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" (let [get-color (fn [done seconds] (cond done :green (< seconds 5) :red :else :cyan))] (is (= :green (get-color true 0))) (is (= :red (get-color false 4))) (is (= :red (get-color false 1))) (is (= :cyan (get-color false 5))) (is (= :cyan (get-color false 10)))))) ;; ============================================================================= ;; SPINNER EXAMPLE TESTS ;; ============================================================================= (def spinner-styles {:dots ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] :line ["|" "/" "-" "\\"] :circle ["◐" "◓" "◑" "◒"] :square ["◰" "◳" "◲" "◱"] :triangle ["◢" "◣" "◤" "◥"] :bounce ["⠁" "⠂" "⠄" "⠂"] :dots2 ["⣾" "⣽" "⣻" "⢿" "⡿" "⣟" "⣯" "⣷"] :arc ["◜" "◠" "◝" "◞" "◡" "◟"] :moon ["🌑" "🌒" "🌓" "🌔" "🌕" "🌖" "🌗" "🌘"]}) (deftest spinner-frame-cycling-test (testing "spinner frame cycles through all frames" (let [spinner-view (fn [frame style] (let [frames (get spinner-styles style) idx (mod frame (count frames))] (nth frames idx)))] ;; Dots style has 10 frames (is (= "⠋" (spinner-view 0 :dots))) (is (= "⠙" (spinner-view 1 :dots))) (is (= "⠋" (spinner-view 10 :dots))) ; Wraps around (is (= "⠙" (spinner-view 11 :dots))) ;; Line style has 4 frames (is (= "|" (spinner-view 0 :line))) (is (= "/" (spinner-view 1 :line))) (is (= "|" (spinner-view 4 :line))) ; Wraps around ;; Circle style (is (= "◐" (spinner-view 0 :circle))) (is (= "◐" (spinner-view 4 :circle)))))) ; Wraps around after 4 frames (deftest spinner-tick-advances-frame-test (testing "spinner tick advances frame when loading" (let [update-fn (fn [{:keys [model event]}] (if (= (:type event) :spinner-frame) (if (:loading model) {:model (update model :frame inc) :events [(ev/delayed-event 80 {:type :spinner-frame})]} {:model model}) {:model model}))] ;; Tick advances frame when loading (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 [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 (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 [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)) 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 [{:keys [model event]}] (if (ev/key= event \space) {:model (assoc model :loading false :message "Done!")} {:model model}))] (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 [{: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 [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 ;; ============================================================================= (deftest list-selection-initial-model-test (testing "list selection initial model structure" (let [initial-model {:cursor 0 :items ["Pizza" "Sushi" "Tacos" "Burger" "Pasta"] :selected #{} :submitted false}] (is (= 0 (:cursor initial-model))) (is (= 5 (count (:items initial-model)))) (is (empty? (:selected initial-model))) (is (false? (:submitted initial-model)))))) (deftest list-selection-cursor-navigation-test (testing "cursor navigation respects bounds" (let [items ["A" "B" "C" "D" "E"] update-fn (fn [{:keys [model event]}] (cond (or (ev/key= event :up) (ev/key= event \k)) {:model (update model :cursor #(max 0 (dec %)))} (or (ev/key= event :down) (ev/key= event \j)) {:model (update model :cursor #(min (dec (count items)) (inc %)))} :else {:model model}))] ;; Move down through list (let [m0 {:cursor 0} 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))) (is (= 4 (:cursor m4))) (is (= 4 (:cursor m5)))) ; Clamped at max ;; Move up from top (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 [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 [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 (: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 [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 [{:keys [model event]}] (if (ev/key= event :enter) {:model (assoc model :submitted true) :events [(ev/quit)]} {:model model}))] (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" (let [item-count-text (fn [n] (str n " item" (when (not= 1 n) "s") " selected"))] (is (= "0 items selected" (item-count-text 0))) (is (= "1 item selected" (item-count-text 1))) (is (= "2 items selected" (item-count-text 2))) (is (= "5 items selected" (item-count-text 5)))))) ;; ============================================================================= ;; VIEWS EXAMPLE TESTS ;; ============================================================================= (deftest views-initial-model-test (testing "views initial model structure" (let [initial-model {:view :menu :cursor 0 :items [{:name "Profile" :desc "Profile settings"} {:name "Settings" :desc "App preferences"}] :selected nil}] (is (= :menu (:view initial-model))) (is (= 0 (:cursor initial-model))) (is (nil? (:selected initial-model)))))) (deftest views-menu-navigation-test (testing "menu view cursor navigation" (let [items [{:name "A"} {:name "B"} {:name "C"} {:name "D"}] update-fn (fn [{:keys [model event]}] (cond (or (ev/key= event :up) (ev/key= event \k)) {:model (update model :cursor #(max 0 (dec %)))} (or (ev/key= event :down) (ev/key= event \j)) {:model (update model :cursor #(min (dec (count items)) (inc %)))} :else {:model model}))] ;; Navigate down (let [result (update-fn {:model {:cursor 0} :event {:type :key :key \j}})] (is (= 1 (:cursor (:model result))))) ;; Navigate up (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 [model event]}] (case (:view model) :menu (cond (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 (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 (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 [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 [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 [result (update-fn {:model {:view :detail :selected {:name "X"}} :event {:type :key :key \b}})] (is (= :menu (:view (:model result))))) ;; Detail -> Confirm via q (let [result (update-fn {:model {:view :detail} :event {:type :key :key \q}})] (is (= :confirm (:view (:model result))))) ;; Confirm -> Quit via y (let [result (update-fn {:model {:view :confirm} :event {:type :key :key \y}})] (is (= [(ev/quit)] (:events result)))) ;; Confirm -> Detail via n (let [result (update-fn {:model {:view :confirm} :event {:type :key :key \n}})] (is (= :detail (:view (:model result))))) ;; Confirm -> Detail via escape (let [result (update-fn {:model {:view :confirm} :event {:type :key :key :escape}})] (is (= :detail (:view (:model result)))))))) ;; ============================================================================= ;; HTTP EXAMPLE TESTS ;; ============================================================================= (deftest http-initial-model-test (testing "http initial model structure" (let [initial-model {:state :idle :status nil :error nil :url "https://httpstat.us/200"}] (is (= :idle (:state initial-model))) (is (nil? (:status initial-model))) (is (nil? (:error initial-model))) (is (string? (:url initial-model)))))) (deftest http-state-machine-test (testing "http state transitions" (let [update-fn (fn [{:keys [model event]}] (cond ;; Start request (and (= (:state model) :idle) (ev/key= event :enter)) {:model (assoc model :state :loading) :events [(ev/shell ["curl" "-s" (:url model)] {:type :http-result})]} ;; Reset (ev/key= event \r) {:model (assoc model :state :idle :status nil :error 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 model}))] ;; Idle -> Loading via enter (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 [result (update-fn {:model {:state :loading} :event {:type :key :key :enter}})] (is (= :loading (:state (:model result)))) (is (nil? (:events result)))) ;; Loading -> Success (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 [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 [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" (let [render-state (fn [state status error] (case state :idle [:text {:fg :gray} "Press enter to fetch..."] :loading [:row {:gap 1} [:text {:fg :yellow} "⠋"] [:text "Fetching..."]] :success [:row {:gap 1} [:text {:fg :green} "✓"] [:text (str "Status: " status)]] :error [:col [:row {:gap 1} [:text {:fg :red} "✗"] [:text {:fg :red} "Error:"]] [:text {:fg :red} error]]))] ;; Idle state (let [view (render-state :idle nil nil)] (is (= :text (first view))) (is (str/includes? (render/render view) "Press enter"))) ;; Loading state (let [view (render-state :loading nil nil)] (is (= :row (first view))) (is (str/includes? (render/render view) "Fetching"))) ;; Success state (let [view (render-state :success 200 nil)] (is (str/includes? (render/render view) "Status: 200"))) ;; Error state (let [view (render-state :error nil "Connection refused")] (is (str/includes? (render/render view) "Error")) (is (str/includes? (render/render view) "Connection refused"))))))