diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..75b8fa2 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/bb.edn b/bb.edn index 0c88777..cd628dd 100644 --- a/bb.edn +++ b/bb.edn @@ -1,20 +1,31 @@ -{:paths ["src" "examples"] +{:paths ["src" "."] :tasks - {counter {:doc "Run counter example" - :task (do (require '[examples.counter]) - ((resolve 'examples.counter/-main)))} - timer {:doc "Run timer example" - :task (do (require '[examples.timer]) - ((resolve 'examples.timer/-main)))} - list {:doc "Run list selection example" - :task (do (require '[examples.list-selection]) - ((resolve 'examples.list-selection/-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)))}}} + {test {:doc "Run all tests (requires Clojure)" + :task (shell "clojure -M:test")} + + examples {:doc "List available examples" + :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.")} + + counter {:doc "Run counter example" + :task (do (require '[examples.counter]) + ((resolve 'examples.counter/-main)))} + + timer {:doc "Run timer example" + :task (do (require '[examples.timer]) + ((resolve 'examples.timer/-main)))} + + list {:doc "Run list selection example" + :task (do (require '[examples.list-selection]) + ((resolve 'examples.list-selection/-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)))}}} diff --git a/deps.edn b/deps.edn index 2e60629..d5721f9 100644 --- a/deps.edn +++ b/deps.edn @@ -2,7 +2,11 @@ :deps {org.clojure/clojure {:mvn/version "1.12.0"} org.clojure/core.async {:mvn/version "1.6.681"}} :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"]} :timer {:main-opts ["-m" "examples.timer"]} :list {:main-opts ["-m" "examples.list-selection"]} diff --git a/src/tui/core.clj b/src/tui/core.clj index 51ac196..9f70c57 100644 --- a/src/tui/core.clj +++ b/src/tui/core.clj @@ -4,7 +4,7 @@ [tui.input :as input] [tui.render :as render] [tui.ansi :as ansi] - [clojure.core.async :as async :refer [go go-loop chan ! >!! ! >!! ! msg-chan key-msg)) - (recur)))) + (async/thread + (loop [] + (when @running? + (when-let [key-msg (input/read-key)] + (>!! msg-chan key-msg)) + (recur))))) ;; === Main Run Loop === (defn run @@ -109,8 +111,8 @@ frame-time (/ 1000 fps)] ;; Setup terminal - (term/init-input!) (term/raw-mode!) + (term/init-input!) (when alt-screen (term/alt-screen!)) (term/clear!) @@ -131,7 +133,7 @@ last-render (System/currentTimeMillis)] (let [;; Wait for message with timeout for frame limiting remaining (max 1 (- frame-time (- (System/currentTimeMillis) last-render))) - msg (alt! + msg (alt!! msg-chan ([v] v) (timeout remaining) nil)] diff --git a/src/tui/render.clj b/src/tui/render.clj index ab2da62..6e7a8a7 100644 --- a/src/tui/render.clj +++ b/src/tui/render.clj @@ -4,6 +4,15 @@ [clojure.string :as str])) ;; === 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 "Parse hiccup element into [tag attrs children]." [elem] @@ -14,8 +23,8 @@ (vector? elem) (let [[tag & rest] elem [attrs children] (if (map? (first rest)) - [(first rest) (vec (next rest))] - [{} (vec rest)])] + [(first rest) (flatten-children (next rest))] + [{} (flatten-children rest)])] [tag attrs children]) :else [:text {} [(str elem)]])) @@ -82,7 +91,9 @@ ;; Calculate content width max-content-width (apply max 0 (map ansi/visible-length lines)) 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) ;; Pad lines @@ -101,8 +112,8 @@ ;; Build box top-line (str (:tl chars) (if title - (str " " title " " - (apply str (repeat (- content-width (count title) 3) (:h chars)))) + (str (:h chars) " " title " " + (apply str (repeat (- content-width (count title) 4) (:h chars)))) (apply str (repeat content-width (:h chars)))) (:tr chars)) bottom-line (str (:bl chars) diff --git a/src/tui/simple.clj b/src/tui/simple.clj index 0ba6e16..fa0d1b5 100644 --- a/src/tui/simple.clj +++ b/src/tui/simple.clj @@ -35,8 +35,8 @@ :or {alt-screen false}}] ;; Setup terminal - (term/init-input!) (term/raw-mode!) + (term/init-input!) (when alt-screen (term/alt-screen!)) (term/clear!) diff --git a/src/tui/terminal.clj b/src/tui/terminal.clj index a89157b..2bf4883 100644 --- a/src/tui/terminal.clj +++ b/src/tui/terminal.clj @@ -1,23 +1,37 @@ (ns tui.terminal "Terminal management: raw mode, size, input/output." (:require [tui.ansi :as ansi] - [clojure.java.io :as io] - [clojure.java.shell :refer [sh]]) + [clojure.java.io :as io]) (:import [java.io BufferedReader InputStreamReader])) ;; === Terminal State === (def ^:private original-stty (atom nil)) (defn- stty [& args] - (let [result (apply sh "stty" (concat args [:in (io/file "/dev/tty")]))] - (when (zero? (:exit result)) - (clojure.string/trim (:out result))))) + (let [cmd (concat ["sh" "-c" (str "stty " (clojure.string/join " " args) " + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +> 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 + + + + + + +──────────────────────────────────────────────────────────────────────────────── diff --git a/test/e2e/output/counter.ascii b/test/e2e/output/counter.ascii new file mode 100644 index 0000000..e7ae9b3 --- /dev/null +++ b/test/e2e/output/counter.ascii @@ -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 + + + + + + +──────────────────────────────────────────────────────────────────────────────── diff --git a/test/e2e/output/counter.gif b/test/e2e/output/counter.gif new file mode 100644 index 0000000..7f9a613 Binary files /dev/null and b/test/e2e/output/counter.gif differ diff --git a/test/tui/ansi_test.clj b/test/tui/ansi_test.clj new file mode 100644 index 0000000..acead06 --- /dev/null +++ b/test/tui/ansi_test.clj @@ -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))))) diff --git a/test/tui/api_test.clj b/test/tui/api_test.clj new file mode 100644 index 0000000..84464dd --- /dev/null +++ b/test/tui/api_test.clj @@ -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}]))))) diff --git a/test/tui/core_test.clj b/test/tui/core_test.clj new file mode 100644 index 0000000..1999a3a --- /dev/null +++ b/test/tui/core_test.clj @@ -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 >!! 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))))) diff --git a/test/tui/edge_cases_test.clj b/test/tui/edge_cases_test.clj new file mode 100644 index 0000000..0e20d4e --- /dev/null +++ b/test/tui/edge_cases_test.clj @@ -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"))))) diff --git a/test/tui/examples_test.clj b/test/tui/examples_test.clj new file mode 100644 index 0000000..034c03c --- /dev/null +++ b/test/tui/examples_test.clj @@ -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")))))) diff --git a/test/tui/input_test.clj b/test/tui/input_test.clj new file mode 100644 index 0000000..164ebb7 --- /dev/null +++ b/test/tui/input_test.clj @@ -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))))) diff --git a/test/tui/render_test.clj b/test/tui/render_test.clj new file mode 100644 index 0000000..d5f48a3 --- /dev/null +++ b/test/tui/render_test.clj @@ -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"))))) diff --git a/test/tui/simple_test.clj b/test/tui/simple_test.clj new file mode 100644 index 0000000..feedbab --- /dev/null +++ b/test/tui/simple_test.clj @@ -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)))))))