(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")))))