This commit is contained in:
2026-01-21 10:30:07 -05:00
parent a990076b03
commit b14ba33c3a
20 changed files with 3718 additions and 43 deletions
+18
View File
@@ -0,0 +1,18 @@
# VHS E2E test for Babashka counter
Output test/e2e/output/bb_counter.ascii
Require bb
Set Shell "bash"
Set FontSize 14
Set Width 800
Set Height 400
Type "bb counter"
Enter
Sleep 3s
Type "k"
Sleep 500ms
Type "q"
Sleep 1s
+46
View File
@@ -0,0 +1,46 @@
# VHS E2E test for counter example
# Tests basic Elm architecture flow: render → input → update → render
Output test/e2e/output/counter.gif
Output test/e2e/output/counter.ascii
Require clojure
Set Shell "bash"
Set FontSize 14
Set Width 800
Set Height 400
Set Framerate 10
# Run the counter example
Type "clojure -A:dev -M -m examples.counter"
Enter
Sleep 2s
# Test increment with 'k' key
Type "k"
Sleep 500ms
Type "k"
Sleep 500ms
Type "k"
Sleep 500ms
# Test decrement with 'j' key
Type "j"
Sleep 500ms
# Test up arrow
Up
Sleep 500ms
# Test down arrow
Down
Sleep 500ms
# Test reset with 'r'
Type "r"
Sleep 500ms
# Quit with 'q'
Type "q"
Sleep 1s
+375
View File
@@ -0,0 +1,375 @@
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
> bb counter
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
+629
View File
@@ -0,0 +1,629 @@
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────
> clojure -A:dev -M -m examples.counter
────────────────────────────────────────────────────────────────────────────────
> clojure -A:dev -M -m examples.counter
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 1 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 3 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 3 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 3 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 3 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 2 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
╭──────────╮
│ Counter │
│ │
│ Count: 0 │
╰──────────╯
j/k or up/do
wn: change value
r: reset q: quit
────────────────────────────────────────────────────────────────────────────────
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+199
View File
@@ -0,0 +1,199 @@
(ns tui.ansi-test
"Unit tests for ANSI escape codes and string utilities."
(:require [clojure.test :refer [deftest testing is]]
[clojure.string :as str]
[tui.ansi :as ansi]))
;; === Style Tests ===
(deftest style-foreground-test
(testing "applies foreground colors"
(let [result (ansi/style "text" :fg :red)]
(is (str/includes? result "31m"))
(is (str/includes? result "text"))
(is (str/ends-with? result ansi/reset))))
(testing "applies bright foreground colors"
(let [result (ansi/style "text" :fg :bright-red)]
(is (str/includes? result "91m"))))
(testing "applies gray/grey alias"
(let [gray (ansi/style "text" :fg :gray)
grey (ansi/style "text" :fg :grey)]
(is (= gray grey))
(is (str/includes? gray "90m")))))
(deftest style-background-test
(testing "applies background colors"
(let [result (ansi/style "text" :bg :blue)]
(is (str/includes? result "44m"))
(is (str/includes? result "text")))))
(deftest style-attributes-test
(testing "applies bold"
(let [result (ansi/style "text" :bold true)]
(is (str/includes? result "1m"))))
(testing "applies dim"
(let [result (ansi/style "text" :dim true)]
(is (str/includes? result "2m"))))
(testing "applies italic"
(let [result (ansi/style "text" :italic true)]
(is (str/includes? result "3m"))))
(testing "applies underline"
(let [result (ansi/style "text" :underline true)]
(is (str/includes? result "4m"))))
(testing "applies inverse"
(let [result (ansi/style "text" :inverse true)]
(is (str/includes? result "7m"))))
(testing "applies strikethrough"
(let [result (ansi/style "text" :strike true)]
(is (str/includes? result "9m")))))
(deftest style-combined-test
(testing "combines multiple styles"
(let [result (ansi/style "text" :fg :red :bold true :underline true)]
(is (str/includes? result "31")) ; Red
(is (str/includes? result "1")) ; Bold
(is (str/includes? result "4"))))) ; Underline
(deftest style-no-styles-test
(testing "returns plain text when no styles"
(is (= "text" (ansi/style "text")))))
;; === Color Helper Tests ===
(deftest fg-helper-test
(testing "fg helper applies foreground color"
(let [result (ansi/fg :green "text")]
(is (str/includes? result "32m"))
(is (str/includes? result "text")))))
(deftest bg-helper-test
(testing "bg helper applies background color"
(let [result (ansi/bg :yellow "text")]
(is (str/includes? result "43m"))
(is (str/includes? result "text")))))
;; === 256 Color Tests ===
(deftest fg-256-test
(testing "applies 256-color foreground"
(let [result (ansi/fg-256 196 "text")]
(is (str/includes? result "38;5;196m"))
(is (str/includes? result "text")))))
(deftest bg-256-test
(testing "applies 256-color background"
(let [result (ansi/bg-256 21 "text")]
(is (str/includes? result "48;5;21m"))
(is (str/includes? result "text")))))
;; === True Color Tests ===
(deftest fg-rgb-test
(testing "applies RGB foreground"
(let [result (ansi/fg-rgb 255 128 64 "text")]
(is (str/includes? result "38;2;255;128;64m"))
(is (str/includes? result "text")))))
(deftest bg-rgb-test
(testing "applies RGB background"
(let [result (ansi/bg-rgb 0 128 255 "text")]
(is (str/includes? result "48;2;0;128;255m"))
(is (str/includes? result "text")))))
;; === String Utility Tests ===
(deftest visible-length-test
(testing "returns length of plain text"
(is (= 5 (ansi/visible-length "hello")))
(is (= 0 (ansi/visible-length ""))))
(testing "excludes ANSI codes from length"
(let [styled (ansi/style "hello" :fg :red :bold true)]
(is (= 5 (ansi/visible-length styled)))))
(testing "handles multiple ANSI sequences"
(let [text (str (ansi/fg :red "red") (ansi/fg :blue "blue"))]
(is (= 7 (ansi/visible-length text))))))
(deftest pad-right-test
(testing "pads string to width"
(is (= "hi " (ansi/pad-right "hi" 5)))
(is (= "hello" (ansi/pad-right "hello" 5))))
(testing "does not truncate if longer"
(is (= "hello" (ansi/pad-right "hello" 3))))
(testing "handles styled text"
(let [styled (ansi/style "hi" :fg :red)
padded (ansi/pad-right styled 5)]
(is (= 5 (ansi/visible-length padded))))))
(deftest pad-left-test
(testing "pads string on left"
(is (= " hi" (ansi/pad-left "hi" 5)))
(is (= "hello" (ansi/pad-left "hello" 5))))
(testing "does not truncate if longer"
(is (= "hello" (ansi/pad-left "hello" 3)))))
(deftest pad-center-test
(testing "centers string"
(is (= " hi " (ansi/pad-center "hi" 5)))
(is (= " hi " (ansi/pad-center "hi" 6))))
(testing "handles odd padding"
(is (= " x " (ansi/pad-center "x" 3)))))
(deftest truncate-test
(testing "truncates long strings"
(is (= "hel\u2026" (ansi/truncate "hello" 4)))
(is (= "h\u2026" (ansi/truncate "hello" 2))))
(testing "does not truncate short strings"
(is (= "hi" (ansi/truncate "hi" 5)))
(is (= "hello" (ansi/truncate "hello" 5)))))
;; === Box Characters Tests ===
(deftest box-chars-test
(testing "all border styles have required characters"
(doseq [[style chars] ansi/box-chars]
(is (contains? chars :tl) (str style " missing :tl"))
(is (contains? chars :tr) (str style " missing :tr"))
(is (contains? chars :bl) (str style " missing :bl"))
(is (contains? chars :br) (str style " missing :br"))
(is (contains? chars :h) (str style " missing :h"))
(is (contains? chars :v) (str style " missing :v")))))
;; === Escape Sequence Constants Tests ===
(deftest escape-sequences-test
(testing "escape sequences are strings"
(is (string? ansi/clear-screen))
(is (string? ansi/clear-line))
(is (string? ansi/cursor-home))
(is (string? ansi/hide-cursor))
(is (string? ansi/show-cursor))
(is (string? ansi/enter-alt-screen))
(is (string? ansi/exit-alt-screen)))
(testing "escape sequences start with ESC"
(is (str/starts-with? ansi/clear-screen "\u001b"))
(is (str/starts-with? ansi/cursor-home "\u001b"))))
(deftest cursor-movement-test
(testing "cursor-to generates correct sequence"
(is (= "\u001b[5;10H" (ansi/cursor-to 5 10))))
(testing "cursor movement functions"
(is (= "\u001b[3A" (ansi/cursor-up 3)))
(is (= "\u001b[2B" (ansi/cursor-down 2)))
(is (= "\u001b[4C" (ansi/cursor-forward 4)))
(is (= "\u001b[1D" (ansi/cursor-back 1)))))
+577
View File
@@ -0,0 +1,577 @@
(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}])))))
+173
View File
@@ -0,0 +1,173 @@
(ns tui.core-test
"Integration tests for the TUI engine.
Tests the update loop, command handling, and full render pipeline."
(:require [clojure.test :refer [deftest testing is]]
[clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]]
[tui.core :as tui]
[tui.render :as render]))
;; === Command Tests ===
(deftest quit-command-test
(testing "quit command is correct vector"
(is (= [:quit] tui/quit))))
(deftest tick-command-test
(testing "tick creates correct command"
(is (= [:tick 100] (tui/tick 100)))
(is (= [:tick 1000] (tui/tick 1000)))))
(deftest batch-command-test
(testing "batch combines commands"
(is (= [:batch [:tick 100] [:quit]] (tui/batch (tui/tick 100) tui/quit))))
(testing "batch filters nil commands"
(is (= [:batch [:tick 100]] (tui/batch nil (tui/tick 100) nil)))))
(deftest sequentially-command-test
(testing "sequentially creates seq command"
(is (= [:seq [:tick 100] [:quit]] (tui/sequentially (tui/tick 100) tui/quit))))
(testing "sequentially filters nil commands"
(is (= [:seq [:tick 100]] (tui/sequentially nil (tui/tick 100) nil)))))
(deftest send-msg-command-test
(testing "send-msg creates function that returns message"
(let [cmd (tui/send-msg {:type :custom :data 42})]
(is (fn? cmd))
(is (= {:type :custom :data 42} (cmd))))))
;; === Key Matching Tests ===
(deftest key=-test
(testing "key= delegates to input/key-match?"
(is (tui/key= [:key {:char \q}] "q"))
(is (tui/key= [:key :enter] :enter))
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))
(is (not (tui/key= [:key {:char \a}] "b")))))
(deftest key-str-test
(testing "key-str converts key to string"
(is (= "q" (tui/key-str [:key {:char \q}])))
(is (= "enter" (tui/key-str [:key :enter])))))
;; === Full Pipeline Tests ===
(deftest render-pipeline-test
(testing "model -> view -> render produces valid output"
(let [model {:count 5}
view (fn [{:keys [count]}]
[:col
[:text {:bold true} "Counter"]
[:text (str "Count: " count)]])
rendered (render/render (view model))]
(is (string? rendered))
(is (clojure.string/includes? rendered "Counter"))
(is (clojure.string/includes? rendered "Count: 5")))))
(deftest update-function-contract-test
(testing "update function returns [model cmd] tuple"
(let [update-fn (fn [model msg]
(cond
(tui/key= msg "q") [model tui/quit]
(tui/key= msg :up) [(update model :n inc) nil]
:else [model nil]))
model {:n 0}]
;; Test quit returns command
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
(is (= model new-model))
(is (= [:quit] cmd)))
;; Test up returns updated model
(let [[new-model cmd] (update-fn model [:key :up])]
(is (= {:n 1} new-model))
(is (nil? cmd)))
;; Test unknown key returns model unchanged
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
(is (= model new-model))
(is (nil? cmd))))))
;; === Command Execution Tests ===
;; These test the internal command execution logic
(deftest execute-quit-command-test
(testing "quit command puts :quit on channel"
(let [msg-chan (chan 1)]
(#'tui/execute-cmd! [:quit] msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 100) :timeout)]
(is (= [:quit] result)))
(close! msg-chan))))
(deftest execute-tick-command-test
(testing "tick command sends :tick message after delay"
(let [msg-chan (chan 1)]
(#'tui/execute-cmd! [:tick 50] msg-chan)
;; Should not receive immediately
(let [immediate (alt!!
msg-chan ([v] v)
(timeout 10) :timeout)]
(is (= :timeout immediate)))
;; Should receive after delay
(let [delayed (alt!!
msg-chan ([v] v)
(timeout 200) :timeout)]
(is (vector? delayed))
(is (= :tick (first delayed))))
(close! msg-chan))))
(deftest execute-function-command-test
(testing "function command executes and sends result"
(let [msg-chan (chan 1)
cmd (fn [] {:custom :message})]
(#'tui/execute-cmd! cmd msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 100) :timeout)]
(is (= {:custom :message} result)))
(close! msg-chan))))
(deftest execute-batch-command-test
(testing "batch executes multiple commands"
(let [msg-chan (chan 10)]
(#'tui/execute-cmd! [:batch
(fn [] :msg1)
(fn [] :msg2)]
msg-chan)
;; Give time for async execution
(Thread/sleep 50)
(let [results (loop [msgs []]
(let [msg (alt!!
msg-chan ([v] v)
(timeout 10) nil)]
(if msg
(recur (conj msgs msg))
msgs)))]
(is (= #{:msg1 :msg2} (set results))))
(close! msg-chan))))
(deftest execute-nil-command-test
(testing "nil command does nothing"
(let [msg-chan (chan 1)]
(#'tui/execute-cmd! nil msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 50) :timeout)]
(is (= :timeout result)))
(close! msg-chan))))
;; === Defapp Macro Tests ===
(deftest defapp-macro-test
(testing "defapp creates app map"
(tui/defapp test-app
:init {:count 0}
:update (fn [m msg] [m nil])
:view (fn [m] [:text "test"]))
(is (map? test-app))
(is (= {:count 0} (:init test-app)))
(is (fn? (:update test-app)))
(is (fn? (:view test-app)))))
+403
View File
@@ -0,0 +1,403 @@
(ns tui.edge-cases-test
"Edge case tests for all TUI modules.
Tests boundary conditions, error handling, and unusual inputs."
(:require [clojure.test :refer [deftest testing is are]]
[clojure.string :as str]
[tui.core :as tui]
[tui.render :as render]
[tui.input :as input]
[tui.ansi :as ansi]))
;; =============================================================================
;; RENDER EDGE CASES
;; =============================================================================
(deftest render-empty-elements-test
(testing "empty col renders as empty string"
(is (= "" (render/render [:col]))))
(testing "empty row renders as empty string"
(is (= "" (render/render [:row]))))
(testing "empty text renders as empty string"
(is (= "" (render/render [:text]))))
(testing "nil renders as empty string"
(is (= "" (render/render nil)))))
(deftest render-nested-empty-test
(testing "nested empty elements produce minimal output"
;; Col with empty rows produces newlines between them
(is (= "\n" (render/render [:col [:row] [:row]])))
;; Row with empty cols produces empty string (no gap)
(is (= "" (render/render [:row [:col] [:col]])))))
(deftest render-single-element-test
(testing "single element col"
(is (= "hello" (render/render [:col "hello"]))))
(testing "single element row"
(is (= "hello" (render/render [:row "hello"])))))
(deftest render-special-characters-test
(testing "renders unicode characters"
(is (= "✓" (render/render [:text "✓"])))
(is (= "⠋" (render/render [:text "⠋"])))
(is (= "🌑" (render/render [:text "🌑"])))
(is (= "╭──╮" (render/render [:row "╭" "──" "╮"]))))
(testing "renders newlines in text"
(is (= "a\nb" (render/render [:text "a\nb"])))))
(deftest render-multiline-content-in-row-test
(testing "multiline elements in row"
(let [result (render/render [:row [:col "a" "b"] " " [:col "c" "d"]])]
(is (str/includes? result "a"))
(is (str/includes? result "b"))
(is (str/includes? result "c"))
(is (str/includes? result "d")))))
(deftest render-deeply-nested-test
(testing "deeply nested structure"
(let [result (render/render
[:col
[:row
[:col
[:row
[:text "deep"]]]]])]
(is (= "deep" result)))))
(deftest render-box-edge-cases-test
(testing "box with empty content has corners and sides"
(let [result (render/render [:box ""])]
(is (str/includes? result "╭")) ; Has corner
(is (str/includes? result "│"))))
(testing "box with very long content"
(let [long-text (apply str (repeat 100 "x"))
result (render/render [:box long-text])]
(is (str/includes? result long-text))))
(testing "box with multiline content"
(let [result (render/render [:box [:col "line1" "line2" "line3"]])]
(is (str/includes? result "line1"))
(is (str/includes? result "line2"))
(is (str/includes? result "line3"))))
(testing "box with all padding formats"
;; Single value
(is (string? (render/render [:box {:padding 1} "x"])))
;; Two values [v h]
(is (string? (render/render [:box {:padding [1 2]} "x"])))
;; Four values [t r b l]
(is (string? (render/render [:box {:padding [1 2 3 4]} "x"])))
;; Invalid (defaults to 0)
(is (string? (render/render [:box {:padding [1 2 3]} "x"])))))
(deftest render-space-edge-cases-test
(testing "space with zero width"
(is (= "" (render/render [:space {:width 0}]))))
(testing "space with large dimensions"
(let [result (render/render [:space {:width 5 :height 3}])]
(is (= " \n \n " result)))))
(deftest render-gap-edge-cases-test
(testing "col with gap 0"
(is (= "a\nb" (render/render [:col {:gap 0} "a" "b"]))))
(testing "row with gap 0"
(is (= "ab" (render/render [:row {:gap 0} "a" "b"]))))
(testing "col with large gap"
(let [result (render/render [:col {:gap 3} "a" "b"])]
(is (= "a\n\n\n\nb" result))))
(testing "row with large gap"
(let [result (render/render [:row {:gap 5} "a" "b"])]
(is (= "a b" result)))))
(deftest render-styled-text-combinations-test
(testing "all style attributes combined"
(let [result (render/render [:text {:bold true
:dim true
:italic true
:underline true
:inverse true
:strike true
:fg :red
:bg :blue}
"styled"])]
(is (str/includes? result "styled"))
(is (str/includes? result "\u001b["))))
(testing "unknown fg color defaults gracefully"
(let [result (render/render [:text {:fg :nonexistent} "text"])]
(is (str/includes? result "text"))))
(testing "numeric values render as strings"
(is (= "42" (render/render 42)))
(is (= "3.14" (render/render 3.14)))
(is (= "-10" (render/render -10)))))
;; =============================================================================
;; KEY MATCHING EDGE CASES
;; =============================================================================
(deftest key-match-edge-cases-test
(testing "empty string pattern"
(is (not (input/key-match? [:key {:char \a}] ""))))
(testing "multi-char string pattern only matches first char"
;; The current implementation only looks at first char
(is (input/key-match? [:key {:char \q}] "quit")))
(testing "nil message returns nil"
(is (nil? (input/key-match? nil "q")))
(is (nil? (input/key-match? nil :enter))))
(testing "non-key message returns nil"
(is (nil? (input/key-match? [:tick 123] "q")))
(is (nil? (input/key-match? [:http-success 200] :enter)))
(is (nil? (input/key-match? "not a vector" "q"))))
(testing "unknown key message structure"
(is (not (input/key-match? [:key {:unknown true}] "q")))
(is (not (input/key-match? [:key {}] "q")))))
(deftest key-str-edge-cases-test
(testing "nil message returns nil"
(is (nil? (input/key->str nil))))
(testing "non-key message returns nil"
(is (nil? (input/key->str [:tick 123])))
(is (nil? (input/key->str [:custom :message]))))
(testing "key message with empty map"
(is (= "" (input/key->str [:key {}]))))
(testing "ctrl and alt combined"
;; This is an edge case - both modifiers
(is (= "ctrl+alt+x" (input/key->str [:key {:ctrl true :alt true :char \x}])))))
;; =============================================================================
;; COMMAND EDGE CASES
;; =============================================================================
(deftest batch-edge-cases-test
(testing "batch with all nils"
(is (= [:batch] (tui/batch nil nil nil))))
(testing "batch with single command"
(is (= [:batch tui/quit] (tui/batch tui/quit))))
(testing "batch with no arguments"
(is (= [:batch] (tui/batch))))
(testing "batch with many commands"
(let [cmd (tui/batch (tui/tick 1) (tui/tick 2) (tui/tick 3) (tui/tick 4) (tui/tick 5))]
(is (= 6 (count cmd))) ; :batch + 5 commands
(is (= :batch (first cmd))))))
(deftest sequentially-edge-cases-test
(testing "sequentially with all nils"
(is (= [:seq] (tui/sequentially nil nil nil))))
(testing "sequentially with single command"
(is (= [:seq tui/quit] (tui/sequentially tui/quit))))
(testing "sequentially with no arguments"
(is (= [:seq] (tui/sequentially)))))
(deftest tick-edge-cases-test
(testing "tick with zero"
(is (= [:tick 0] (tui/tick 0))))
(testing "tick with very large value"
(is (= [:tick 999999999] (tui/tick 999999999)))))
(deftest send-msg-edge-cases-test
(testing "send-msg with nil"
(let [cmd (tui/send-msg nil)]
(is (fn? cmd))
(is (nil? (cmd)))))
(testing "send-msg with complex message"
(let [msg {:type :complex :data [1 2 3] :nested {:a :b}}
cmd (tui/send-msg msg)]
(is (= msg (cmd))))))
;; =============================================================================
;; ANSI EDGE CASES
;; =============================================================================
(deftest visible-length-edge-cases-test
(testing "empty string"
(is (= 0 (ansi/visible-length ""))))
(testing "only ANSI codes"
(is (= 0 (ansi/visible-length "\u001b[31m\u001b[0m"))))
(testing "multiple ANSI sequences"
(let [text (str (ansi/fg :red "a") (ansi/fg :blue "b") (ansi/fg :green "c"))]
(is (= 3 (ansi/visible-length text))))))
(deftest pad-right-edge-cases-test
(testing "pad to width 0"
(is (= "hello" (ansi/pad-right "hello" 0))))
(testing "pad empty string"
(is (= " " (ansi/pad-right "" 5))))
(testing "pad already wider"
(is (= "hello world" (ansi/pad-right "hello world" 5)))))
(deftest pad-left-edge-cases-test
(testing "pad to width 0"
(is (= "hello" (ansi/pad-left "hello" 0))))
(testing "pad empty string"
(is (= " " (ansi/pad-left "" 5)))))
(deftest pad-center-edge-cases-test
(testing "center in width 0"
(is (= "hi" (ansi/pad-center "hi" 0))))
(testing "center empty string"
(is (= " " (ansi/pad-center "" 3))))
(testing "center in exact width"
(is (= "hello" (ansi/pad-center "hello" 5)))))
(deftest truncate-edge-cases-test
(testing "truncate to 0"
(is (= "…" (ansi/truncate "hello" 1))))
(testing "truncate empty string"
(is (= "" (ansi/truncate "" 5))))
(testing "truncate exact length"
(is (= "hello" (ansi/truncate "hello" 5)))))
(deftest style-edge-cases-test
(testing "style with no attributes"
(is (= "text" (ansi/style "text"))))
(testing "style empty string"
(let [result (ansi/style "" :bold true)]
(is (str/includes? result ansi/reset)))))
(deftest color-functions-edge-cases-test
(testing "fg with default color"
(let [result (ansi/fg :default "text")]
(is (str/includes? result "39m"))))
(testing "bg with default color"
(let [result (ansi/bg :default "text")]
(is (str/includes? result "49m"))))
(testing "256-color boundary values"
(is (string? (ansi/fg-256 0 "text")))
(is (string? (ansi/fg-256 255 "text"))))
(testing "RGB boundary values"
(is (string? (ansi/fg-rgb 0 0 0 "text")))
(is (string? (ansi/fg-rgb 255 255 255 "text")))))
;; =============================================================================
;; UPDATE FUNCTION EDGE CASES
;; =============================================================================
(deftest update-with-unknown-messages-test
(testing "update function handles unknown messages gracefully"
(let [update-fn (fn [model msg]
(cond
(tui/key= msg "q") [model tui/quit]
:else [model nil]))]
;; Unknown key
(let [[m cmd] (update-fn {:n 0} [:key {:char \x}])]
(is (= {:n 0} m))
(is (nil? cmd)))
;; Unknown message type
(let [[m cmd] (update-fn {:n 0} [:unknown :message])]
(is (= {:n 0} m))
(is (nil? cmd)))
;; Empty message
(let [[m cmd] (update-fn {:n 0} [])]
(is (= {:n 0} m))
(is (nil? cmd))))))
(deftest model-with-complex-state-test
(testing "model with nested data structures"
(let [complex-model {:count 0
:items ["a" "b" "c"]
:nested {:deep {:value 42}}
:selected #{}
:history []}
update-fn (fn [model msg]
(if (tui/key= msg :up)
[(-> model
(update :count inc)
(update :history conj (:count model)))
nil]
[model nil]))]
(let [[m1 _] (update-fn complex-model [:key :up])
[m2 _] (update-fn m1 [:key :up])]
(is (= 1 (:count m1)))
(is (= [0] (:history m1)))
(is (= 2 (:count m2)))
(is (= [0 1] (:history m2)))
;; Other fields unchanged
(is (= ["a" "b" "c"] (:items m2)))
(is (= 42 (get-in m2 [:nested :deep :value])))))))
;; =============================================================================
;; VIEW FUNCTION EDGE CASES
;; =============================================================================
(deftest view-with-conditional-rendering-test
(testing "view handles nil children gracefully"
(let [view (fn [show-extra]
[:col
[:text "always"]
(when show-extra
[:text "sometimes"])])]
(let [result1 (render/render (view true))
result2 (render/render (view false))]
(is (str/includes? result1 "always"))
(is (str/includes? result1 "sometimes"))
(is (str/includes? result2 "always"))
(is (not (str/includes? result2 "sometimes"))))))
(testing "view with for generating elements"
(let [view (fn [items]
[:col
(for [item items]
[:text item])])]
(is (string? (render/render (view ["a" "b" "c"]))))
(is (string? (render/render (view []))))))) ; Empty list
(deftest view-with-dynamic-styles-test
(testing "dynamic style based on state"
(let [view (fn [{:keys [error loading success]}]
[:text {:fg (cond
error :red
loading :yellow
success :green
:else :default)}
(cond
error "Error!"
loading "Loading..."
success "Done!"
:else "Idle")])]
(is (str/includes? (render/render (view {:error true})) "Error!"))
(is (str/includes? (render/render (view {:loading true})) "Loading"))
(is (str/includes? (render/render (view {:success true})) "Done!"))
(is (str/includes? (render/render (view {})) "Idle")))))
+612
View File
@@ -0,0 +1,612 @@
(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"))))))
+94
View File
@@ -0,0 +1,94 @@
(ns tui.input-test
"Unit tests for input parsing and key matching."
(:require [clojure.test :refer [deftest testing is]]
[tui.input :as input]))
;; === Key Matching Tests ===
(deftest key-match-character-test
(testing "matches single character keys"
(is (input/key-match? [:key {:char \q}] "q"))
(is (input/key-match? [:key {:char \a}] "a"))
(is (input/key-match? [:key {:char \1}] "1")))
(testing "does not match different characters"
(is (not (input/key-match? [:key {:char \q}] "a")))
(is (not (input/key-match? [:key {:char \x}] "y"))))
(testing "does not match ctrl+char as plain char"
(is (not (input/key-match? [:key {:ctrl true :char \c}] "c"))))
(testing "does not match alt+char as plain char"
(is (not (input/key-match? [:key {:alt true :char \x}] "x")))))
(deftest key-match-special-keys-test
(testing "matches special keys by keyword"
(is (input/key-match? [:key :enter] :enter))
(is (input/key-match? [:key :escape] :escape))
(is (input/key-match? [:key :backspace] :backspace))
(is (input/key-match? [:key :tab] :tab)))
(testing "matches arrow keys"
(is (input/key-match? [:key :up] :up))
(is (input/key-match? [:key :down] :down))
(is (input/key-match? [:key :left] :left))
(is (input/key-match? [:key :right] :right)))
(testing "matches function keys"
(is (input/key-match? [:key :f1] :f1))
(is (input/key-match? [:key :f12] :f12)))
(testing "does not match wrong special keys"
(is (not (input/key-match? [:key :up] :down)))
(is (not (input/key-match? [:key :enter] :escape)))))
(deftest key-match-ctrl-combo-test
(testing "matches ctrl+char combinations"
(is (input/key-match? [:key {:ctrl true :char \c}] [:ctrl \c]))
(is (input/key-match? [:key {:ctrl true :char \x}] [:ctrl \x]))
(is (input/key-match? [:key {:ctrl true :char \z}] [:ctrl \z])))
(testing "does not match wrong ctrl combinations"
(is (not (input/key-match? [:key {:ctrl true :char \c}] [:ctrl \x])))
(is (not (input/key-match? [:key {:char \c}] [:ctrl \c])))))
(deftest key-match-alt-combo-test
(testing "matches alt+char combinations"
(is (input/key-match? [:key {:alt true :char \x}] [:alt \x]))
(is (input/key-match? [:key {:alt true :char \a}] [:alt \a])))
(testing "does not match wrong alt combinations"
(is (not (input/key-match? [:key {:alt true :char \x}] [:alt \y])))
(is (not (input/key-match? [:key {:char \x}] [:alt \x])))))
(deftest key-match-non-key-messages-test
(testing "returns nil for non-key messages"
(is (nil? (input/key-match? [:tick 123] "q")))
(is (nil? (input/key-match? [:quit] :enter)))
(is (nil? (input/key-match? nil "a")))))
;; === Key to String Tests ===
(deftest key->str-special-keys-test
(testing "converts special keys to strings"
(is (= "enter" (input/key->str [:key :enter])))
(is (= "escape" (input/key->str [:key :escape])))
(is (= "up" (input/key->str [:key :up])))
(is (= "f1" (input/key->str [:key :f1])))))
(deftest key->str-character-keys-test
(testing "converts character keys to strings"
(is (= "q" (input/key->str [:key {:char \q}])))
(is (= "a" (input/key->str [:key {:char \a}])))))
(deftest key->str-modifier-keys-test
(testing "converts ctrl combinations to strings"
(is (= "ctrl+c" (input/key->str [:key {:ctrl true :char \c}]))))
(testing "converts alt combinations to strings"
(is (= "alt+x" (input/key->str [:key {:alt true :char \x}])))))
(deftest key->str-non-key-messages-test
(testing "returns nil for non-key messages"
(is (nil? (input/key->str [:tick 123])))
(is (nil? (input/key->str nil)))))
+156
View File
@@ -0,0 +1,156 @@
(ns tui.render-test
"Unit tests for hiccup rendering."
(:require [clojure.test :refer [deftest testing is]]
[clojure.string :as str]
[tui.render :as render]
[tui.ansi :as ansi]))
;; === Text Rendering Tests ===
(deftest render-plain-text-test
(testing "renders plain strings"
(is (= "hello" (render/render "hello")))
(is (= "world" (render/render "world"))))
(testing "renders numbers as strings"
(is (= "42" (render/render 42)))
(is (= "3.14" (render/render 3.14))))
(testing "renders nil as empty string"
(is (= "" (render/render nil)))))
(deftest render-text-element-test
(testing "renders :text element with string child"
(is (= "hello" (render/render [:text "hello"]))))
(testing "renders :text element with multiple children"
(is (= "hello world" (render/render [:text "hello" " " "world"]))))
(testing "renders nested text"
(is (= "42" (render/render [:text 42])))))
(deftest render-styled-text-test
(testing "renders bold text"
(let [result (render/render [:text {:bold true} "bold"])]
(is (str/includes? result "bold"))
(is (str/includes? result "\u001b[")) ; Contains ANSI escape
(is (str/includes? result "1m")))) ; Bold code
(testing "renders colored text"
(let [result (render/render [:text {:fg :red} "red"])]
(is (str/includes? result "red"))
(is (str/includes? result "31m")))) ; Red foreground code
(testing "renders multiple styles"
(let [result (render/render [:text {:bold true :fg :green} "styled"])]
(is (str/includes? result "styled"))
(is (str/includes? result "1")) ; Bold
(is (str/includes? result "32"))))) ; Green
;; === Layout Tests ===
(deftest render-row-test
(testing "renders row with children horizontally"
(is (= "ab" (render/render [:row "a" "b"])))
(is (= "abc" (render/render [:row "a" "b" "c"]))))
(testing "renders row with gap"
(is (= "a b" (render/render [:row {:gap 1} "a" "b"])))
(is (= "a b" (render/render [:row {:gap 2} "a" "b"]))))
(testing "renders nested elements in row"
(is (= "hello world" (render/render [:row [:text "hello"] " " [:text "world"]])))))
(deftest render-col-test
(testing "renders col with children vertically"
(is (= "a\nb" (render/render [:col "a" "b"])))
(is (= "a\nb\nc" (render/render [:col "a" "b" "c"]))))
(testing "renders col with gap"
(is (= "a\n\nb" (render/render [:col {:gap 1} "a" "b"])))
(is (= "a\n\n\nb" (render/render [:col {:gap 2} "a" "b"]))))
(testing "renders nested elements in col"
(is (= "line1\nline2" (render/render [:col [:text "line1"] [:text "line2"]])))))
(deftest render-nested-layout-test
(testing "renders row inside col"
(is (= "a b\nc d" (render/render [:col
[:row "a" " " "b"]
[:row "c" " " "d"]]))))
(testing "renders col inside row"
(is (= "a\nb c\nd" (render/render [:row
[:col "a" "b"]
" "
[:col "c" "d"]])))))
;; === Box Tests ===
(deftest render-box-test
(testing "renders box with content"
(let [result (render/render [:box "hello"])]
(is (str/includes? result "hello"))
(is (str/includes? result "─")) ; Horizontal border
(is (str/includes? result "│")))) ; Vertical border
(testing "renders box with title"
(let [result (render/render [:box {:title "Title"} "content"])]
(is (str/includes? result "Title"))
(is (str/includes? result "content"))))
(testing "renders box with different border styles"
(let [rounded (render/render [:box {:border :rounded} "x"])
single (render/render [:box {:border :single} "x"])
double (render/render [:box {:border :double} "x"])
ascii (render/render [:box {:border :ascii} "x"])]
(is (str/includes? rounded "╭"))
(is (str/includes? single "┌"))
(is (str/includes? double "╔"))
(is (str/includes? ascii "+")))))
(deftest render-box-padding-test
(testing "renders box with numeric padding"
(let [result (render/render [:box {:padding 1} "x"])
lines (str/split result #"\n")]
;; Should have empty lines for vertical padding
(is (> (count lines) 3))))
(testing "renders box with vector padding"
(let [result (render/render [:box {:padding [0 2]} "x"])]
;; Should have horizontal padding (spaces around content)
(is (str/includes? result " x ")))))
;; === Space Tests ===
(deftest render-space-test
(testing "renders space with default size"
(is (= " " (render/render [:space]))))
(testing "renders space with custom width"
(is (= " " (render/render [:space {:width 3}]))))
(testing "renders space with custom height"
(is (= " \n " (render/render [:space {:height 2}]))))
(testing "renders space with width and height"
(is (= " \n " (render/render [:space {:width 2 :height 2}])))))
;; === Convenience Function Tests ===
(deftest convenience-functions-test
(testing "text function creates text element"
(is (= [:text {} "hello"] (render/text "hello")))
(is (= [:text {:bold true} "bold"] (render/text {:bold true} "bold"))))
(testing "row function creates row element"
(is (= [:row {} "a" "b"] (render/row "a" "b")))
(is (= [:row {:gap 1} "a" "b"] (render/row {:gap 1} "a" "b"))))
(testing "col function creates col element"
(is (= [:col {} "a" "b"] (render/col "a" "b")))
(is (= [:col {:gap 1} "a" "b"] (render/col {:gap 1} "a" "b"))))
(testing "box function creates box element"
(is (= [:box {} "content"] (render/box "content")))
(is (= [:box {:border :single} "x"] (render/box {:border :single} "x")))))
+281
View File
@@ -0,0 +1,281 @@
(ns tui.simple-test
"Unit tests for the simple (synchronous) TUI runtime."
(:require [clojure.test :refer [deftest testing is]]
[tui.simple :as simple]
[tui.render :as render]))
;; =============================================================================
;; QUIT COMMAND TESTS
;; =============================================================================
(deftest quit-command-test
(testing "quit command is correct vector"
(is (= [:quit] simple/quit))
(is (vector? simple/quit))
(is (= :quit (first simple/quit)))))
;; =============================================================================
;; KEY MATCHING TESTS (same API as tui.core)
;; =============================================================================
(deftest key=-character-keys-test
(testing "matches single character keys"
(is (simple/key= [:key {:char \q}] "q"))
(is (simple/key= [:key {:char \a}] "a"))
(is (simple/key= [:key {:char \space}] " ")))
(testing "does not match different characters"
(is (not (simple/key= [:key {:char \q}] "a")))
(is (not (simple/key= [:key {:char \x}] "y")))))
(deftest key=-special-keys-test
(testing "matches special keys by keyword"
(is (simple/key= [:key :enter] :enter))
(is (simple/key= [:key :escape] :escape))
(is (simple/key= [:key :up] :up))
(is (simple/key= [:key :down] :down))
(is (simple/key= [:key :left] :left))
(is (simple/key= [:key :right] :right))
(is (simple/key= [:key :tab] :tab))
(is (simple/key= [:key :backspace] :backspace))))
(deftest key=-ctrl-combos-test
(testing "matches ctrl+char combinations"
(is (simple/key= [:key {:ctrl true :char \c}] [:ctrl \c]))
(is (simple/key= [:key {:ctrl true :char \x}] [:ctrl \x])))
(testing "ctrl combo does not match plain char"
(is (not (simple/key= [:key {:ctrl true :char \c}] "c")))
(is (not (simple/key= [:key {:char \c}] [:ctrl \c])))))
(deftest key=-alt-combos-test
(testing "matches alt+char combinations"
(is (simple/key= [:key {:alt true :char \x}] [:alt \x])))
(testing "alt combo does not match plain char"
(is (not (simple/key= [:key {:alt true :char \x}] "x")))))
(deftest key=-non-key-messages-test
(testing "returns nil for non-key messages"
(is (nil? (simple/key= [:tick 123] "q")))
(is (nil? (simple/key= [:quit] :enter)))
(is (nil? (simple/key= nil "a")))))
;; =============================================================================
;; KEY-STR TESTS
;; =============================================================================
(deftest key-str-test
(testing "converts character keys to strings"
(is (= "q" (simple/key-str [:key {:char \q}])))
(is (= " " (simple/key-str [:key {:char \space}]))))
(testing "converts special keys to strings"
(is (= "enter" (simple/key-str [:key :enter])))
(is (= "escape" (simple/key-str [:key :escape])))
(is (= "up" (simple/key-str [:key :up]))))
(testing "converts modifier keys to strings"
(is (= "ctrl+c" (simple/key-str [:key {:ctrl true :char \c}])))
(is (= "alt+x" (simple/key-str [:key {:alt true :char \x}]))))
(testing "returns nil for non-key messages"
(is (nil? (simple/key-str [:tick 123])))
(is (nil? (simple/key-str nil)))))
;; =============================================================================
;; RENDER RE-EXPORT TESTS
;; =============================================================================
(deftest render-reexport-test
(testing "simple/render is the same as render/render"
(is (= (render/render [:text "hello"])
(simple/render [:text "hello"])))
(is (= (render/render [:col "a" "b"])
(simple/render [:col "a" "b"])))))
;; =============================================================================
;; UPDATE FUNCTION CONTRACT TESTS (same as tui.core)
;; =============================================================================
(deftest simple-update-contract-test
(testing "update function returns [model cmd] tuple"
(let [update-fn (fn [model msg]
(cond
(simple/key= msg "q") [model simple/quit]
(simple/key= msg :up) [(update model :n inc) nil]
:else [model nil]))
model {:n 0}]
;; Quit returns command
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
(is (= model new-model))
(is (= [:quit] cmd)))
;; Up returns updated model
(let [[new-model cmd] (update-fn model [:key :up])]
(is (= {:n 1} new-model))
(is (nil? cmd)))
;; Unknown key returns model unchanged
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
(is (= model new-model))
(is (nil? cmd))))))
;; =============================================================================
;; COUNTER PATTERN TESTS (from counter example, works with simple runtime)
;; =============================================================================
(deftest simple-counter-pattern-test
(testing "counter update pattern without async commands"
(let [update-fn (fn [{:keys [count] :as model} msg]
(cond
(or (simple/key= msg "q")
(simple/key= msg [:ctrl \c]))
[model simple/quit]
(or (simple/key= msg :up)
(simple/key= msg "k"))
[(update model :count inc) nil]
(or (simple/key= msg :down)
(simple/key= msg "j"))
[(update model :count dec) nil]
(simple/key= msg "r")
[(assoc model :count 0) nil]
:else
[model nil]))]
;; Test increment with up arrow
(let [[m1 _] (update-fn {:count 0} [:key :up])]
(is (= 1 (:count m1))))
;; Test increment with k
(let [[m1 _] (update-fn {:count 0} [:key {:char \k}])]
(is (= 1 (:count m1))))
;; Test decrement
(let [[m1 _] (update-fn {:count 5} [:key :down])]
(is (= 4 (:count m1))))
;; Test reset
(let [[m1 _] (update-fn {:count 42} [:key {:char \r}])]
(is (= 0 (:count m1))))
;; Test quit with q
(let [[_ cmd] (update-fn {:count 0} [:key {:char \q}])]
(is (= simple/quit cmd)))
;; Test quit with ctrl+c
(let [[_ cmd] (update-fn {:count 0} [:key {:ctrl true :char \c}])]
(is (= simple/quit cmd))))))
;; =============================================================================
;; LIST SELECTION PATTERN TESTS (works with simple runtime)
;; =============================================================================
(deftest simple-list-selection-pattern-test
(testing "list selection with cursor navigation"
(let [items ["Pizza" "Sushi" "Tacos" "Burger"]
update-fn (fn [{:keys [cursor items] :as model} msg]
(cond
(or (simple/key= msg :up)
(simple/key= msg "k"))
[(update model :cursor #(max 0 (dec %))) nil]
(or (simple/key= msg :down)
(simple/key= msg "j"))
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
(simple/key= msg " ")
[(update model :selected
#(if (contains? % cursor)
(disj % cursor)
(conj % cursor)))
nil]
(simple/key= msg :enter)
[(assoc model :submitted true) simple/quit]
:else
[model nil]))]
;; Test cursor bounds - can't go below 0
(let [[m1 _] (update-fn {:cursor 0 :items items :selected #{}} [:key :up])]
(is (= 0 (:cursor m1))))
;; Test cursor bounds - can't go above max
(let [[m1 _] (update-fn {:cursor 3 :items items :selected #{}} [:key :down])]
(is (= 3 (:cursor m1))))
;; Test toggle selection
(let [m0 {:cursor 1 :items items :selected #{}}
[m1 _] (update-fn m0 [:key {:char \space}])
[m2 _] (update-fn m1 [:key {:char \space}])]
(is (= #{1} (:selected m1)))
(is (= #{} (:selected m2))))
;; Test submission
(let [[m1 cmd] (update-fn {:cursor 0 :items items :selected #{0 2} :submitted false}
[:key :enter])]
(is (:submitted m1))
(is (= simple/quit cmd))))))
;; =============================================================================
;; VIEWS STATE MACHINE TESTS (works with simple runtime)
;; =============================================================================
(deftest simple-views-state-machine-test
(testing "view state transitions"
(let [items [{:name "Profile" :desc "Profile settings"}
{:name "Settings" :desc "App preferences"}]
update-fn (fn [{:keys [view cursor items] :as model} msg]
(case view
:menu
(cond
(simple/key= msg :enter)
[(assoc model :view :detail :selected (nth items cursor)) nil]
(simple/key= msg "q")
[model simple/quit]
:else [model nil])
:detail
(cond
(or (simple/key= msg :escape)
(simple/key= msg "b"))
[(assoc model :view :menu :selected nil) nil]
(simple/key= msg "q")
[(assoc model :view :confirm) nil]
:else [model nil])
:confirm
(cond
(simple/key= msg "y")
[model simple/quit]
(simple/key= msg "n")
[(assoc model :view :detail) nil]
:else [model nil])))]
;; Menu -> Detail
(let [[m1 _] (update-fn {:view :menu :cursor 0 :items items} [:key :enter])]
(is (= :detail (:view m1)))
(is (= "Profile" (:name (:selected m1)))))
;; Detail -> Menu (back)
(let [[m1 _] (update-fn {:view :detail :selected (first items)} [:key :escape])]
(is (= :menu (:view m1)))
(is (nil? (:selected m1))))
;; Detail -> Confirm (quit attempt)
(let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])]
(is (= :confirm (:view m1))))
;; Confirm -> Quit (yes)
(let [[_ cmd] (update-fn {:view :confirm} [:key {:char \y}])]
(is (= simple/quit cmd)))
;; Confirm -> Detail (no)
(let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])]
(is (= :detail (:view m1)))))))