(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.render :as render] [tui.input :as input])) ;; ============================================================================= ;; KEY MATCHING TESTS (tui/key=) ;; Patterns from: counter, timer, list-selection, spinner, views, http ;; ============================================================================= (deftest key=-character-keys-test (testing "from counter: matching q for quit" (is (tui/key= [:key {:char \q}] "q")) (is (not (tui/key= [:key {:char \a}] "q")))) (testing "from counter: matching k/j for navigation" (is (tui/key= [:key {:char \k}] "k")) (is (tui/key= [:key {:char \j}] "j"))) (testing "from counter: matching r for reset" (is (tui/key= [:key {:char \r}] "r"))) (testing "from timer: matching space for pause/resume" (is (tui/key= [:key {:char \space}] " ")) (is (tui/key= [:key {:char \space}] " "))) (testing "from views: matching b for back, y/n for confirm" (is (tui/key= [:key {:char \b}] "b")) (is (tui/key= [:key {:char \y}] "y")) (is (tui/key= [:key {:char \n}] "n")))) (deftest key=-arrow-keys-test (testing "from counter/list-selection: up/down arrows" (is (tui/key= [:key :up] :up)) (is (tui/key= [:key :down] :down)) (is (not (tui/key= [:key :up] :down))) (is (not (tui/key= [:key :left] :up)))) (testing "left/right arrows" (is (tui/key= [:key :left] :left)) (is (tui/key= [:key :right] :right)))) (deftest key=-special-keys-test (testing "from list-selection/http: enter key" (is (tui/key= [:key :enter] :enter))) (testing "from views: escape key" (is (tui/key= [:key :escape] :escape))) (testing "from spinner: tab key" (is (tui/key= [:key :tab] :tab))) (testing "backspace key" (is (tui/key= [:key :backspace] :backspace)))) (deftest key=-ctrl-combos-test (testing "from all examples: ctrl+c for quit" (is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))) (testing "other ctrl combinations" (is (tui/key= [:key {:ctrl true :char \x}] [:ctrl \x])) (is (tui/key= [:key {:ctrl true :char \z}] [:ctrl \z])) (is (tui/key= [:key {:ctrl true :char \a}] [:ctrl \a]))) (testing "ctrl combo does not match plain char" (is (not (tui/key= [:key {:ctrl true :char \c}] "c"))) (is (not (tui/key= [:key {:char \c}] [:ctrl \c]))))) (deftest key=-alt-combos-test (testing "alt+char combinations" (is (tui/key= [:key {:alt true :char \x}] [:alt \x])) (is (tui/key= [:key {:alt true :char \a}] [:alt \a]))) (testing "alt combo does not match plain char" (is (not (tui/key= [:key {:alt true :char \x}] "x"))) (is (not (tui/key= [:key {:char \x}] [:alt \x]))))) (deftest key=-non-key-messages-test (testing "from timer/spinner: tick messages are not keys" (is (not (tui/key= [:tick 123456789] "q"))) (is (not (tui/key= [:tick 123456789] :enter)))) (testing "from http: custom messages are not keys" (is (not (tui/key= [:http-success 200] "q"))) (is (not (tui/key= [:http-error "timeout"] :enter)))) (testing "quit command is not a key" (is (not (tui/key= [:quit] "q"))))) ;; ============================================================================= ;; COMMAND TESTS ;; Patterns from: timer, spinner, http ;; ============================================================================= (deftest quit-command-test (testing "from all examples: tui/quit is [:quit]" (is (= [:quit] tui/quit)) (is (vector? tui/quit)) (is (= :quit (first tui/quit))))) (deftest tick-command-test (testing "from timer: tick with 1000ms" (is (= [:tick 1000] (tui/tick 1000)))) (testing "from spinner: tick with 80ms" (is (= [:tick 80] (tui/tick 80)))) (testing "tick with various intervals" (are [ms] (= [:tick ms] (tui/tick ms)) 0 1 10 100 500 1000 5000 60000))) (deftest batch-command-test (testing "batch two commands" (let [cmd (tui/batch (tui/tick 100) tui/quit)] (is (= [:batch [:tick 100] [:quit]] cmd)))) (testing "batch three commands" (let [cmd (tui/batch (tui/tick 50) (tui/tick 100) tui/quit)] (is (= [:batch [:tick 50] [:tick 100] [:quit]] cmd)))) (testing "batch filters nil" (is (= [:batch [:tick 100]] (tui/batch nil (tui/tick 100) nil))) (is (= [:batch] (tui/batch nil nil nil)))) (testing "batch with single command" (is (= [:batch tui/quit] (tui/batch tui/quit))))) (deftest sequentially-command-test (testing "sequentially two commands" (let [cmd (tui/sequentially (tui/tick 100) tui/quit)] (is (= [:seq [:tick 100] [:quit]] cmd)))) (testing "sequentially filters nil" (is (= [:seq [:tick 100]] (tui/sequentially nil (tui/tick 100) nil)))) (testing "sequentially with functions" (let [f (fn [] :msg) cmd (tui/sequentially f tui/quit)] (is (= 3 (count cmd))) (is (= :seq (first cmd))) (is (fn? (second cmd)))))) (deftest send-msg-command-test (testing "from http pattern: send-msg creates function" (let [cmd (tui/send-msg [:http-success 200])] (is (fn? cmd)) (is (= [:http-success 200] (cmd))))) (testing "send-msg with map" (let [cmd (tui/send-msg {:type :custom :data 42})] (is (= {:type :custom :data 42} (cmd))))) (testing "send-msg with keyword" (let [cmd (tui/send-msg :done)] (is (= :done (cmd)))))) (deftest custom-command-function-test (testing "from http: custom async command pattern" (let [fetch-result (atom nil) cmd (fn [] (reset! fetch-result :fetched) [:http-success 200])] ;; Execute command (is (= [:http-success 200] (cmd))) (is (= :fetched @fetch-result))))) ;; ============================================================================= ;; UPDATE FUNCTION PATTERNS ;; Testing the [model cmd] return contract ;; ============================================================================= (deftest update-returns-tuple-test (testing "update always returns [model cmd] tuple" (let [model {:count 0} ;; Counter-style update update-fn (fn [m msg] (cond (tui/key= msg "q") [m tui/quit] (tui/key= msg :up) [(update m :count inc) nil] :else [m nil]))] ;; Quit returns original model + quit command (let [[new-model cmd] (update-fn model [:key {:char \q}])] (is (= model new-model)) (is (= tui/quit cmd))) ;; Up returns modified model + nil command (let [[new-model cmd] (update-fn model [:key :up])] (is (= {:count 1} new-model)) (is (nil? cmd))) ;; Unknown key returns unchanged model + nil command (let [[new-model cmd] (update-fn model [:key {:char \x}])] (is (= model new-model)) (is (nil? cmd)))))) (deftest counter-update-pattern-test (testing "counter increment/decrement pattern" (let [update-fn (fn [{:keys [count] :as model} msg] (cond (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]))] ;; Test sequence: up, up, down, reset (let [m0 {:count 0} [m1 _] (update-fn m0 [:key :up]) [m2 _] (update-fn m1 [:key {:char \k}]) [m3 _] (update-fn m2 [:key :down]) [m4 _] (update-fn m3 [:key {:char \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 [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]) (tui/key= msg " ") (let [new-running (not running)] [(assoc model :running new-running) (when new-running (tui/tick 1000))]) :else [model nil]))] ;; Test tick countdown (let [m0 {:seconds 3 :running true :done false} [m1 c1] (update-fn m0 [:tick 123]) [m2 c2] (update-fn m1 [:tick 123]) [m3 c3] (update-fn m2 [:tick 123])] (is (= 2 (:seconds m1))) (is (= [:tick 1000] c1)) (is (= 1 (:seconds m2))) (is (= [:tick 1000] c2)) (is (= 0 (:seconds m3))) (is (:done m3)) (is (not (:running m3))) (is (nil? c3))) ;; Test pause/resume (let [m0 {:seconds 5 :running true :done false} [m1 c1] (update-fn m0 [:key {:char \space}]) [m2 c2] (update-fn m1 [:key {:char \space}])] (is (not (:running m1))) (is (nil? c1)) (is (:running m2)) (is (= [:tick 1000] c2)))))) (deftest list-selection-update-pattern-test (testing "cursor navigation with bounds" (let [items ["a" "b" "c" "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]))] ;; Test bounds (let [m0 {:cursor 0} [m1 _] (update-fn m0 [:key :up]) ; Can't go below 0 [m2 _] (update-fn m1 [:key :down]) [m3 _] (update-fn m2 [:key :down]) [m4 _] (update-fn m3 [:key :down]) [m5 _] (update-fn m4 [:key :down])] ; Can't go above 3 (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 [cursor] :as model} msg] (if (tui/key= msg " ") [(update model :selected #(if (contains? % cursor) (disj % cursor) (conj % cursor))) nil] [model nil]))] (let [m0 {:cursor 0 :selected #{}} [m1 _] (update-fn m0 [:key {:char \space}]) [m2 _] (update-fn (assoc m1 :cursor 2) [:key {:char \space}]) [m3 _] (update-fn (assoc m2 :cursor 0) [:key {:char \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 [view] :as model} msg] (case view :menu (cond (tui/key= msg :enter) [(assoc model :view :detail) nil] (tui/key= msg "q") [(assoc model :view :confirm) nil] :else [model nil]) :detail (cond (or (tui/key= msg :escape) (tui/key= msg "b")) [(assoc model :view :menu) nil] (tui/key= msg "q") [(assoc model :view :confirm) nil] :else [model nil]) :confirm (cond (tui/key= msg "y") [model tui/quit] (tui/key= msg "n") [(assoc model :view :detail) nil] :else [model nil])))] ;; Menu -> Detail -> Confirm -> Quit (let [m0 {:view :menu} [m1 _] (update-fn m0 [:key :enter]) [m2 _] (update-fn m1 [:key {:char \q}]) [m3 c3] (update-fn m2 [:key {:char \y}])] (is (= :detail (:view m1))) (is (= :confirm (:view m2))) (is (= tui/quit c3))) ;; Detail -> Menu (back) (let [m0 {:view :detail} [m1 _] (update-fn m0 [:key :escape])] (is (= :menu (:view m1))))))) (deftest http-async-pattern-test (testing "HTTP state machine pattern" (let [update-fn (fn [{:keys [state url] :as model} msg] (cond (and (= state :idle) (tui/key= msg :enter)) [(assoc model :state :loading) (fn [] [:http-success 200])] (= (first msg) :http-success) [(assoc model :state :success :status (second msg)) nil] (= (first msg) :http-error) [(assoc model :state :error :error (second msg)) nil] (tui/key= msg "r") [(assoc model :state :idle :status nil :error nil) nil] :else [model nil]))] ;; Idle -> Loading (let [m0 {:state :idle :url "http://test.com"} [m1 c1] (update-fn m0 [:key :enter])] (is (= :loading (:state m1))) (is (fn? c1))) ;; Loading -> Success (let [m0 {:state :loading} [m1 _] (update-fn m0 [:http-success 200])] (is (= :success (:state m1))) (is (= 200 (:status m1)))) ;; Loading -> Error (let [m0 {:state :loading} [m1 _] (update-fn m0 [:http-error "Connection refused"])] (is (= :error (:state m1))) (is (= "Connection refused" (:error m1)))) ;; Reset (let [m0 {:state :error :error "timeout"} [m1 _] (update-fn m0 [:key {:char \r}])] (is (= :idle (:state m1))) (is (nil? (:error m1))))))) ;; ============================================================================= ;; 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"))))) ;; ============================================================================= ;; KEY-STR TESTS ;; ============================================================================= (deftest key-str-comprehensive-test (testing "character keys" (is (= "q" (tui/key-str [:key {:char \q}]))) (is (= " " (tui/key-str [:key {:char \space}])))) (testing "special keys" (is (= "enter" (tui/key-str [:key :enter]))) (is (= "escape" (tui/key-str [:key :escape]))) (is (= "tab" (tui/key-str [:key :tab]))) (is (= "backspace" (tui/key-str [:key :backspace]))) (is (= "up" (tui/key-str [:key :up]))) (is (= "down" (tui/key-str [:key :down]))) (is (= "left" (tui/key-str [:key :left]))) (is (= "right" (tui/key-str [:key :right])))) (testing "modifier keys" (is (= "ctrl+c" (tui/key-str [:key {:ctrl true :char \c}]))) (is (= "alt+x" (tui/key-str [:key {:alt true :char \x}])))))