(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.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 [model msg] (cond (or (tui/key= msg "q") (tui/key= msg [:ctrl \c])) [model tui/quit] (or (tui/key= msg :up) (tui/key= msg "k")) [(update model :count inc) nil] (or (tui/key= msg :down) (tui/key= msg "j")) [(update model :count dec) nil] (tui/key= msg "r") [(assoc model :count 0) nil] :else [model nil]))] ;; All increment keys (are [msg] (= 1 (:count (first (update-fn {:count 0} msg)))) [:key :up] [:key {:char \k}]) ;; All decrement keys (are [msg] (= -1 (:count (first (update-fn {:count 0} msg)))) [:key :down] [:key {:char \j}]) ;; All quit keys (are [msg] (= tui/quit (second (update-fn {:count 0} msg))) [:key {:char \q}] [:key {:ctrl true :char \c}]) ;; Reset key (is (= 0 (:count (first (update-fn {:count 42} [:key {:char \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 [seconds running] :as model} msg] (cond (= (first msg) :tick) (if running (let [new-seconds (dec seconds)] (if (<= new-seconds 0) [(assoc model :seconds 0 :done true :running false) nil] [(assoc model :seconds new-seconds) (tui/tick 1000)])) [model nil]) :else [model nil]))] ;; Normal tick (let [[m1 c1] (update-fn {:seconds 10 :running true :done false} [:tick 123])] (is (= 9 (:seconds m1))) (is (= [:tick 1000] c1))) ;; Tick to zero (let [[m1 c1] (update-fn {:seconds 1 :running true :done false} [:tick 123])] (is (= 0 (:seconds m1))) (is (true? (:done m1))) (is (false? (:running m1))) (is (nil? c1))) ;; Tick when paused does nothing (let [[m1 c1] (update-fn {:seconds 5 :running false :done false} [:tick 123])] (is (= 5 (:seconds m1))) (is (nil? c1)))))) (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/tick 1000))]) [model nil]))] ;; Pause (running -> not running) (let [[m1 c1] (update-fn {:seconds 5 :running true} [:key {:char \space}])] (is (false? (:running m1))) (is (nil? c1))) ;; Resume (not running -> running) (let [[m1 c1] (update-fn {:seconds 5 :running false} [:key {:char \space}])] (is (true? (:running m1))) (is (= [:tick 1000] c1)))))) (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/tick 1000)] [model nil]))] (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 (= [:tick 1000] c1)))))) (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 [model msg] (if (= (first msg) :tick) (if (:loading model) [(update model :frame inc) (tui/tick 80)] [model nil]) [model nil]))] ;; Tick advances frame when loading (let [[m1 c1] (update-fn {:frame 0 :loading true} [:tick 123])] (is (= 1 (:frame m1))) (is (= [:tick 80] c1))) ;; Tick does nothing when not loading (let [[m1 c1] (update-fn {:frame 5 :loading false} [:tick 123])] (is (= 5 (:frame m1))) (is (nil? c1)))))) (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]))] ;; Tab advances style (let [[m1 _] (update-fn {:style-idx 0 :style (first styles)} [:key :tab])] (is (= 1 (:style-idx m1)))) ;; 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))))))) (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 [[m1 _] (update-fn {:loading true :message "Loading..."} [:key {:char \space}])] (is (false? (:loading m1))) (is (= "Done!" (:message m1))))))) (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/tick 80)] [model nil]))] (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 (= [:tick 80] c1)))))) ;; ============================================================================= ;; 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 [cursor] :as model} msg] (cond (or (tui/key= msg :up) (tui/key= msg "k")) [(update model :cursor #(max 0 (dec %))) nil] (or (tui/key= msg :down) (tui/key= msg "j")) [(update model :cursor #(min (dec (count items)) (inc %))) nil] :else [model nil]))] ;; 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 (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 [[m1 _] (update-fn {:cursor 0} [:key :up])] (is (= 0 (:cursor m1))))))) ; 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]))] ;; Select item (let [[m1 _] (update-fn {:cursor 0 :selected #{}} [:key {:char \space}])] (is (= #{0} (:selected m1)))) ;; 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}])] (is (= #{0 2 4} (:selected m3)))) ;; Deselect item (let [[m1 _] (update-fn {:cursor 1 :selected #{1 2}} [:key {:char \space}])] (is (= #{2} (:selected m1))))))) (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 [[m1 c1] (update-fn {:selected #{0 2} :submitted false} [:key :enter])] (is (true? (:submitted m1))) (is (= tui/quit c1)))))) (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 [cursor] :as model} msg] (cond (or (tui/key= msg :up) (tui/key= msg "k")) [(update model :cursor #(max 0 (dec %))) nil] (or (tui/key= msg :down) (tui/key= msg "j")) [(update model :cursor #(min (dec (count items)) (inc %))) nil] :else [model nil]))] ;; Navigate down (let [[m1 _] (update-fn {:cursor 0} [:key {:char \j}])] (is (= 1 (:cursor m1)))) ;; Navigate up (let [[m1 _] (update-fn {:cursor 2} [:key {:char \k}])] (is (= 1 (:cursor m1))))))) (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 :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]) :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]) :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])))] ;; 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))))) ;; Detail -> Menu via escape (let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key :escape])] (is (= :menu (:view m1))) (is (nil? (:selected m1)))) ;; Detail -> Menu via b (let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key {:char \b}])] (is (= :menu (:view m1)))) ;; Detail -> Confirm via q (let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])] (is (= :confirm (:view m1)))) ;; Confirm -> Quit via y (let [[_ c1] (update-fn {:view :confirm} [:key {:char \y}])] (is (= tui/quit c1))) ;; Confirm -> Detail via n (let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])] (is (= :detail (:view m1)))) ;; Confirm -> Detail via escape (let [[m1 _] (update-fn {:view :confirm} [:key :escape])] (is (= :detail (:view m1))))))) ;; ============================================================================= ;; 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 [state url] :as model} msg] (cond ;; Start request (and (= state :idle) (tui/key= msg :enter)) [(assoc model :state :loading) (fn [] [:http-success 200])] ;; Reset (tui/key= msg "r") [(assoc model :state :idle :status nil :error nil) 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] :else [model nil]))] ;; Idle -> Loading via enter (let [[m1 c1] (update-fn {:state :idle :url "http://test.com"} [:key :enter])] (is (= :loading (:state m1))) (is (fn? c1))) ;; Enter ignored when not idle (let [[m1 c1] (update-fn {:state :loading} [:key :enter])] (is (= :loading (:state m1))) (is (nil? c1))) ;; Loading -> Success (let [[m1 _] (update-fn {:state :loading} [:http-success 200])] (is (= :success (:state m1))) (is (= 200 (:status m1)))) ;; Loading -> Error (let [[m1 _] (update-fn {:state :loading} [:http-error "Connection refused"])] (is (= :error (:state m1))) (is (= "Connection refused" (:error m1)))) ;; 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))))))) (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"))))))