Files
clojure-tui/test/tui/examples_test.clj
2026-01-22 22:22:19 -05:00

613 lines
24 KiB
Clojure

(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
(= msg :timer-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/after 1000 :timer-tick)]))
[model nil])
:else [model nil]))]
;; Normal tick
(let [[m1 c1] (update-fn {:seconds 10 :running true :done false} :timer-tick)]
(is (= 9 (:seconds m1)))
(is (fn? c1)))
;; 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)))
;; 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))))))
(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]))]
;; 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 (fn? 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/after 1000 :timer-tick)]
[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 (fn? 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 (= msg :spinner-frame)
(if (:loading model)
[(update model :frame inc) (tui/after 80 :spinner-frame)]
[model nil])
[model nil]))]
;; Tick advances frame when loading
(let [[m1 c1] (update-fn {:frame 0 :loading true} :spinner-frame)]
(is (= 1 (:frame m1)))
(is (fn? c1)))
;; Tick does nothing when not loading
(let [[m1 c1] (update-fn {:frame 5 :loading false} :spinner-frame)]
(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/after 80 :spinner-frame)]
[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 (fn? 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"))))))