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
+68
View File
@@ -0,0 +1,68 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Clojure TUI framework inspired by Bubbletea (Go), using the Elm Architecture with Hiccup for views. Two runtimes: full async (`tui.core` with core.async) and simple sync (`tui.simple` for Babashka).
## Commands
```bash
# Run tests
bb test
# List available examples
bb examples
# Run examples (with Babashka - simple sync runtime)
bb counter
bb timer
bb list
bb spinner
bb views
bb http
# Run examples with full Clojure (async support)
clojure -A:dev -M -m examples.counter
```
## Architecture
```
View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O)
↑ |
| v
Model ←──────── Update ←─────────── Input (key parsing)
```
### Core Modules
- **tui.core** - Full async runtime with core.async. Manages the event loop, executes commands (quit, tick, batch, seq), handles input via goroutines.
- **tui.simple** - Sync runtime for Babashka. Same API but no async commands.
- **tui.render** - Converts hiccup (`[:col [:text "hi"]]`) to ANSI strings. Handles `:text`, `:row`, `:col`, `:box`, `:space` elements.
- **tui.terminal** - Platform abstraction: raw mode via `stty`, reads from `/dev/tty`, renders by printing ANSI.
- **tui.input** - Parses raw bytes into key messages (`[:key {:char \a}]`, `[:key :up]`, `[:key {:ctrl true :char \c}]`).
- **tui.ansi** - ANSI escape codes, colors, box-drawing characters, string utilities.
### Elm Architecture Flow
1. App provides `:init` (model), `:update` (fn [model msg] -> [new-model cmd]), `:view` (fn [model] -> hiccup)
2. Runtime renders initial view
3. Input loop reads keys, puts messages on channel
4. Update function processes messages, may return commands
5. Commands execute async (ticks, batches), put results back on channel
6. Loop until `[:quit]` command
### Command Types
- `tui/quit` - Exit
- `(tui/tick ms)` - Send `:tick` after delay
- `(tui/batch cmd1 cmd2)` - Parallel execution
- `(tui/sequentially cmd1 cmd2)` - Sequential execution
- `(fn [] msg)` - Custom async returning a message
## Testing Philosophy
- **E2E tests**: Small number of integration tests to verify the full stack works (terminal init → input → update → render → cleanup)
- **Unit tests**: Cover all engine behavior for rendering (hiccup→ANSI), input handling (byte sequences→key messages), and model/command interactions
+30 -19
View File
@@ -1,20 +1,31 @@
{:paths ["src" "examples"] {:paths ["src" "."]
:tasks :tasks
{counter {:doc "Run counter example" {test {:doc "Run all tests (requires Clojure)"
:task (do (require '[examples.counter]) :task (shell "clojure -M:test")}
((resolve 'examples.counter/-main)))}
timer {:doc "Run timer example" examples {:doc "List available examples"
:task (do (require '[examples.timer]) :task (println "Available examples:\n bb counter - Simple counter\n bb timer - Timer with ticks\n bb list - List selection\n bb spinner - Animated spinner\n bb views - Multi-view navigation\n bb http - HTTP requests\n\nOr run with Clojure for full async support:\n clojure -A:dev -M -m examples.<name>")}
((resolve 'examples.timer/-main)))}
list {:doc "Run list selection example" counter {:doc "Run counter example"
:task (do (require '[examples.list-selection]) :task (do (require '[examples.counter])
((resolve 'examples.list-selection/-main)))} ((resolve 'examples.counter/-main)))}
spinner {:doc "Run spinner example"
:task (do (require '[examples.spinner]) timer {:doc "Run timer example"
((resolve 'examples.spinner/-main)))} :task (do (require '[examples.timer])
views {:doc "Run multi-view example" ((resolve 'examples.timer/-main)))}
:task (do (require '[examples.views])
((resolve 'examples.views/-main)))} list {:doc "Run list selection example"
http {:doc "Run HTTP example" :task (do (require '[examples.list-selection])
:task (do (require '[examples.http]) ((resolve 'examples.list-selection/-main)))}
((resolve 'examples.http/-main)))}}}
spinner {:doc "Run spinner example"
:task (do (require '[examples.spinner])
((resolve 'examples.spinner/-main)))}
views {:doc "Run multi-view example"
:task (do (require '[examples.views])
((resolve 'examples.views/-main)))}
http {:doc "Run HTTP example"
:task (do (require '[examples.http])
((resolve 'examples.http/-main)))}}}
+5 -1
View File
@@ -2,7 +2,11 @@
:deps {org.clojure/clojure {:mvn/version "1.12.0"} :deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/core.async {:mvn/version "1.6.681"}} org.clojure/core.async {:mvn/version "1.6.681"}}
:aliases :aliases
{:dev {:extra-paths ["examples"]} {:dev {:extra-paths ["." "test"]}
:test {:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
:counter {:main-opts ["-m" "examples.counter"]} :counter {:main-opts ["-m" "examples.counter"]}
:timer {:main-opts ["-m" "examples.timer"]} :timer {:main-opts ["-m" "examples.timer"]}
:list {:main-opts ["-m" "examples.list-selection"]} :list {:main-opts ["-m" "examples.list-selection"]}
+11 -9
View File
@@ -4,7 +4,7 @@
[tui.input :as input] [tui.input :as input]
[tui.render :as render] [tui.render :as render]
[tui.ansi :as ansi] [tui.ansi :as ansi]
[clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt!]])) [clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt! alt!!]]))
;; === Command Types === ;; === Command Types ===
;; nil - no-op ;; nil - no-op
@@ -81,13 +81,15 @@
;; === Input Loop === ;; === Input Loop ===
(defn- start-input-loop! (defn- start-input-loop!
"Start goroutine that reads input and puts messages on channel." "Start thread that reads input and puts messages on channel.
Uses thread instead of go-loop because input reading is blocking I/O."
[msg-chan running?] [msg-chan running?]
(go-loop [] (async/thread
(when @running? (loop []
(when-let [key-msg (input/read-key)] (when @running?
(>! msg-chan key-msg)) (when-let [key-msg (input/read-key)]
(recur)))) (>!! msg-chan key-msg))
(recur)))))
;; === Main Run Loop === ;; === Main Run Loop ===
(defn run (defn run
@@ -109,8 +111,8 @@
frame-time (/ 1000 fps)] frame-time (/ 1000 fps)]
;; Setup terminal ;; Setup terminal
(term/init-input!)
(term/raw-mode!) (term/raw-mode!)
(term/init-input!)
(when alt-screen (term/alt-screen!)) (when alt-screen (term/alt-screen!))
(term/clear!) (term/clear!)
@@ -131,7 +133,7 @@
last-render (System/currentTimeMillis)] last-render (System/currentTimeMillis)]
(let [;; Wait for message with timeout for frame limiting (let [;; Wait for message with timeout for frame limiting
remaining (max 1 (- frame-time (- (System/currentTimeMillis) last-render))) remaining (max 1 (- frame-time (- (System/currentTimeMillis) last-render)))
msg (alt! msg (alt!!
msg-chan ([v] v) msg-chan ([v] v)
(timeout remaining) nil)] (timeout remaining) nil)]
+16 -5
View File
@@ -4,6 +4,15 @@
[clojure.string :as str])) [clojure.string :as str]))
;; === Hiccup Parsing === ;; === Hiccup Parsing ===
(defn- flatten-children
"Flatten sequences in children (but not vectors, which are hiccup elements)."
[children]
(vec (mapcat (fn [child]
(if (and (sequential? child) (not (vector? child)))
(flatten-children child)
[child]))
children)))
(defn- parse-element (defn- parse-element
"Parse hiccup element into [tag attrs children]." "Parse hiccup element into [tag attrs children]."
[elem] [elem]
@@ -14,8 +23,8 @@
(vector? elem) (vector? elem)
(let [[tag & rest] elem (let [[tag & rest] elem
[attrs children] (if (map? (first rest)) [attrs children] (if (map? (first rest))
[(first rest) (vec (next rest))] [(first rest) (flatten-children (next rest))]
[{} (vec rest)])] [{} (flatten-children rest)])]
[tag attrs children]) [tag attrs children])
:else [:text {} [(str elem)]])) :else [:text {} [(str elem)]]))
@@ -82,7 +91,9 @@
;; Calculate content width ;; Calculate content width
max-content-width (apply max 0 (map ansi/visible-length lines)) max-content-width (apply max 0 (map ansi/visible-length lines))
inner-width (+ max-content-width pad-left pad-right) inner-width (+ max-content-width pad-left pad-right)
box-width (or width (+ inner-width 2)) ;; Title needs: "─ title " = title-length + 3
title-width (if title (+ (count title) 3) 0)
box-width (or width (+ (max inner-width title-width) 2))
content-width (- box-width 2) content-width (- box-width 2)
;; Pad lines ;; Pad lines
@@ -101,8 +112,8 @@
;; Build box ;; Build box
top-line (str (:tl chars) top-line (str (:tl chars)
(if title (if title
(str " " title " " (str (:h chars) " " title " "
(apply str (repeat (- content-width (count title) 3) (:h chars)))) (apply str (repeat (- content-width (count title) 4) (:h chars))))
(apply str (repeat content-width (:h chars)))) (apply str (repeat content-width (:h chars))))
(:tr chars)) (:tr chars))
bottom-line (str (:bl chars) bottom-line (str (:bl chars)
+1 -1
View File
@@ -35,8 +35,8 @@
:or {alt-screen false}}] :or {alt-screen false}}]
;; Setup terminal ;; Setup terminal
(term/init-input!)
(term/raw-mode!) (term/raw-mode!)
(term/init-input!)
(when alt-screen (term/alt-screen!)) (when alt-screen (term/alt-screen!))
(term/clear!) (term/clear!)
+24 -8
View File
@@ -1,23 +1,37 @@
(ns tui.terminal (ns tui.terminal
"Terminal management: raw mode, size, input/output." "Terminal management: raw mode, size, input/output."
(:require [tui.ansi :as ansi] (:require [tui.ansi :as ansi]
[clojure.java.io :as io] [clojure.java.io :as io])
[clojure.java.shell :refer [sh]])
(:import [java.io BufferedReader InputStreamReader])) (:import [java.io BufferedReader InputStreamReader]))
;; === Terminal State === ;; === Terminal State ===
(def ^:private original-stty (atom nil)) (def ^:private original-stty (atom nil))
(defn- stty [& args] (defn- stty [& args]
(let [result (apply sh "stty" (concat args [:in (io/file "/dev/tty")]))] (let [cmd (concat ["sh" "-c" (str "stty " (clojure.string/join " " args) " </dev/tty")]
(when (zero? (:exit result)) (when (empty? args) ["sh" "-c" "stty </dev/tty"]))
(clojure.string/trim (:out result))))) pb (ProcessBuilder. ^java.util.List (vec cmd))
_ (.inheritIO pb)
proc (.start pb)
exit (.waitFor proc)]
(when (zero? exit)
"")))
(defn- stty-get [& args]
(let [cmd ["sh" "-c" (str "stty " (clojure.string/join " " args) " </dev/tty")]
pb (ProcessBuilder. ^java.util.List (vec cmd))
_ (.redirectInput pb java.lang.ProcessBuilder$Redirect/INHERIT)
proc (.start pb)
output (slurp (.getInputStream proc))
exit (.waitFor proc)]
(when (zero? exit)
(clojure.string/trim output))))
(defn get-terminal-size (defn get-terminal-size
"Get terminal dimensions as [width height]." "Get terminal dimensions as [width height]."
[] []
(try (try
(let [result (stty "size")] (let [result (stty-get "size")]
(when result (when result
(let [[rows cols] (map parse-long (clojure.string/split result #"\s+"))] (let [[rows cols] (map parse-long (clojure.string/split result #"\s+"))]
{:width cols :height rows}))) {:width cols :height rows})))
@@ -27,7 +41,7 @@
(defn raw-mode! (defn raw-mode!
"Enter raw terminal mode (no echo, no line buffering)." "Enter raw terminal mode (no echo, no line buffering)."
[] []
(reset! original-stty (stty "-g")) (reset! original-stty (stty-get "-g"))
(stty "raw" "-echo" "-icanon" "min" "1") (stty "raw" "-echo" "-icanon" "min" "1")
(print ansi/hide-cursor) (print ansi/hide-cursor)
(flush)) (flush))
@@ -66,7 +80,9 @@
[s] [s]
(print ansi/cursor-home) (print ansi/cursor-home)
(print ansi/clear-to-end) (print ansi/clear-to-end)
(print s) ;; In raw mode, \n only moves down without returning to column 0
;; Replace \n with \r\n to get proper line breaks
(print (clojure.string/replace s "\n" "\r\n"))
(flush)) (flush))
;; === Input Handling === ;; === Input Handling ===
+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)))))))