Files
clojure-tui/test/tui/api_test.clj

554 lines
23 KiB
Clojure

(ns tui.api-test
"Comprehensive unit tests for user-facing API functions.
Test cases derived from actual usage patterns in 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]
[tui.input :as input]))
;; =============================================================================
;; 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 (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 (ev/key= {:type :key :key \k} \k))
(is (ev/key= {:type :key :key \j} \j)))
(testing "from counter: matching r for reset"
(is (ev/key= {:type :key :key \r} \r)))
(testing "from timer: matching space for pause/resume"
(is (ev/key= {:type :key :key \space} \space)))
(testing "from views: matching b for back, y/n for confirm"
(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 (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 (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 (ev/key= {:type :key :key :enter} :enter)))
(testing "from views: escape key"
(is (ev/key= {:type :key :key :escape} :escape)))
(testing "from spinner: tab key"
(is (ev/key= {:type :key :key :tab} :tab)))
(testing "backspace key"
(is (ev/key= {:type :key :key :backspace} :backspace))))
(deftest key=-ctrl-combos-test
(testing "from all examples: ctrl+c for quit"
(is (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c #{:ctrl})))
(testing "other ctrl combinations"
(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 (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 (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 (ev/key= {:type :key :key \x :modifiers #{:alt}} \x)))
(is (not (ev/key= {:type :key :key \x} \x #{:alt})))))
(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 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 event is not a key"
(is (not (ev/key= {:type :quit} \q)))))
;; =============================================================================
;; EVENT CONSTRUCTOR TESTS
;; Patterns from: timer, spinner, http
;; =============================================================================
(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 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: delayed-event with short interval"
(let [event (ev/delayed-event 80 {:type :spinner-frame})]
(is (= :delayed-event (:type event)))
(is (= 80 (:ms event))))))
(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 [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 event"
(let [event (ev/batch {:type :msg1})]
(is (= :batch (:type event)))
(is (= 1 (count (:events event)))))))
(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 "sequential filters nil"
(let [event (ev/sequential nil {:type :msg1} nil)]
(is (= :sequential (:type event)))
(is (= 1 (count (:events event))))))
(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 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))))))
(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 m :events [...]} return contract
;; =============================================================================
(deftest update-returns-map-test
(testing "update always returns {:model m :events [...]}"
(let [model {:count 0}
;; Counter-style update
update-fn (fn [{:keys [model event]}]
(cond
(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 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, 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, 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 [model event]}]
(cond
(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}))]
;; Test sequence: up, up, down, reset
(let [m0 {:count 0}
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)))
(is (= 0 (:count m4)))))))
(deftest timer-update-pattern-test
(testing "timer tick handling pattern"
(let [update-fn (fn [{:keys [model event]}]
(cond
(= (: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})
(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 model}))]
;; Test tick countdown
(let [m0 {:seconds 3 :running true :done false}
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}
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 [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}))]
;; Test bounds
(let [m0 {:cursor 0}
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)))
(is (= 3 (:cursor m4)))
(is (= 3 (:cursor m5))))))
(testing "toggle selection pattern"
(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 (: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 [model event]}]
(case (:view model)
:menu
(cond
(ev/key= event :enter)
{:model (assoc model :view :detail)}
(ev/key= event \q)
{:model (assoc model :view :confirm)}
:else {:model model})
:detail
(cond
(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
(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}
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}
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 [model event]}]
(cond
(and (= (:state model) :idle) (ev/key= event :enter))
{:model (assoc model :state :loading)
:events [(ev/shell ["curl" "-s" (:url model)]
{:type :http-result})]}
(= (: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)}))
(ev/key= event \r)
{:model (assoc model :state :idle :status nil :error nil)}
:else
{:model model}))]
;; Idle -> Loading
(let [m0 {:state :idle :url "http://test.com"}
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}
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}
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"}
r1 (update-fn {:model m0 :event {:type :key :key \r}})]
(is (= :idle (:state (:model r1))))
(is (nil? (:error (:model r1))))))))
;; =============================================================================
;; RENDER TESTS
;; Testing hiccup patterns from examples
;; =============================================================================
(deftest render-text-styles-test
(testing "from counter: bold title"
(let [result (render/render [:text {:bold true} "Counter"])]
(is (str/includes? result "Counter"))
(is (str/includes? result "\u001b[1m"))))
(testing "from counter: conditional fg color"
(let [pos-result (render/render [:text {:fg :green} "Count: 5"])
neg-result (render/render [:text {:fg :red} "Count: -3"])
zero-result (render/render [:text {:fg :default} "Count: 0"])]
(is (str/includes? pos-result "32m")) ; Green
(is (str/includes? neg-result "31m")) ; Red
(is (str/includes? zero-result "Count: 0"))))
(testing "from timer: multiple styles"
(let [result (render/render [:text {:fg :cyan :bold true} "00:10"])]
(is (str/includes? result "36")) ; Cyan
(is (str/includes? result "1")))) ; Bold
(testing "from views: italic style"
(let [result (render/render [:text {:fg :gray :italic true} "Help text"])]
(is (str/includes? result "3m"))))) ; Italic
(deftest render-layout-patterns-test
(testing "from counter: col with gap"
(let [result (render/render [:col {:gap 1}
[:text "Line 1"]
[:text "Line 2"]])]
(is (str/includes? result "Line 1"))
(is (str/includes? result "Line 2"))
;; Gap of 1 means extra newline between items
(is (str/includes? result "\n\n"))))
(testing "from list-selection: row with gap"
(let [result (render/render [:row {:gap 1}
[:text ">"]
[:text "[x]"]
[:text "Pizza"]])]
(is (str/includes? result ">"))
(is (str/includes? result "[x]"))
(is (str/includes? result "Pizza"))))
(testing "from views: nested col in row"
(let [result (render/render [:row {:gap 2}
[:text "A"]
[:text "B"]
[:text "C"]])]
(is (= "A B C" result)))))
(deftest render-box-patterns-test
(testing "from counter: rounded border with padding"
(let [result (render/render [:box {:border :rounded :padding [0 1]}
[:text "Content"]])]
(is (str/includes? result "╭")) ; Rounded corner
(is (str/includes? result "╯"))
(is (str/includes? result "Content"))))
(testing "from list-selection: box with title"
(let [result (render/render [:box {:border :rounded :title "Menu"}
[:text "Item 1"]])]
(is (str/includes? result "Menu"))
(is (str/includes? result "Item 1"))))
(testing "from views: double border"
(let [result (render/render [:box {:border :double}
[:text "Detail"]])]
(is (str/includes? result "╔")) ; Double corner
(is (str/includes? result "║")))) ; Double vertical
(testing "box with complex padding"
(let [result (render/render [:box {:padding [1 2]}
[:text "X"]])]
;; Should have vertical and horizontal padding
(is (str/includes? result "X")))))
(deftest render-dynamic-content-test
(testing "from list-selection: generating items with into"
;; Use into to build hiccup with dynamic children
(let [items ["Pizza" "Sushi" "Tacos"]
result (render/render
(into [:col]
(for [item items]
[:text item])))]
(is (str/includes? result "Pizza"))
(is (str/includes? result "Sushi"))
(is (str/includes? result "Tacos"))))
(testing "conditional rendering"
(let [loading? true
result (render/render
(if loading?
[:text {:fg :yellow} "Loading..."]
[:text {:fg :green} "Done"]))]
(is (str/includes? result "Loading..."))))
(testing "from http: case-based view selection"
(let [render-state (fn [state]
(render/render
(case state
:idle [:text "Press enter"]
:loading [:text {:fg :yellow} "Fetching..."]
:success [:text {:fg :green} "Done"]
:error [:text {:fg :red} "Failed"])))]
(is (str/includes? (render-state :idle) "Press enter"))
(is (str/includes? (render-state :loading) "Fetching"))
(is (str/includes? (render-state :success) "Done"))
(is (str/includes? (render-state :error) "Failed")))))
(deftest render-complex-view-test
(testing "from counter: full view structure"
(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: change value"]])
result (render/render (view {:count 5}))]
(is (str/includes? result "Counter"))
(is (str/includes? result "Count: 5"))
(is (str/includes? result "j/k: change value"))
(is (str/includes? result "╭")))) ; Has box
(testing "from list-selection: cursor indicator"
(let [render-item (fn [idx cursor item selected]
(let [is-cursor (= idx cursor)
is-selected (contains? selected idx)]
[:row {:gap 1}
[:text (if is-cursor ">" " ")]
[:text (if is-selected "[x]" "[ ]")]
[:text {:bold is-cursor} item]]))
result (render/render
[:col
(render-item 0 0 "Pizza" #{0})
(render-item 1 0 "Sushi" #{})
(render-item 2 0 "Tacos" #{})])]
;; Note: bold text includes ANSI codes, so check for components
(is (str/includes? result "> [x]"))
(is (str/includes? result "Pizza"))
(is (str/includes? result "[ ] Sushi"))
(is (str/includes? result "[ ] Tacos")))))