From b14ba33c3a35df8b8f6c295ddee80e01958cdbd3 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Wed, 21 Jan 2026 10:30:07 -0500 Subject: [PATCH] init --- CLAUDE.md | 68 ++++ bb.edn | 49 ++- deps.edn | 6 +- src/tui/core.clj | 20 +- src/tui/render.clj | 21 +- src/tui/simple.clj | 2 +- src/tui/terminal.clj | 32 +- test/e2e/bb_counter.tape | 18 + test/e2e/counter.tape | 46 +++ test/e2e/output/bb_counter.ascii | 375 ++++++++++++++++++ test/e2e/output/counter.ascii | 629 +++++++++++++++++++++++++++++++ test/e2e/output/counter.gif | Bin 0 -> 20097 bytes test/tui/ansi_test.clj | 199 ++++++++++ test/tui/api_test.clj | 577 ++++++++++++++++++++++++++++ test/tui/core_test.clj | 173 +++++++++ test/tui/edge_cases_test.clj | 403 ++++++++++++++++++++ test/tui/examples_test.clj | 612 ++++++++++++++++++++++++++++++ test/tui/input_test.clj | 94 +++++ test/tui/render_test.clj | 156 ++++++++ test/tui/simple_test.clj | 281 ++++++++++++++ 20 files changed, 3718 insertions(+), 43 deletions(-) create mode 100644 CLAUDE.md create mode 100644 test/e2e/bb_counter.tape create mode 100644 test/e2e/counter.tape create mode 100644 test/e2e/output/bb_counter.ascii create mode 100644 test/e2e/output/counter.ascii create mode 100644 test/e2e/output/counter.gif create mode 100644 test/tui/ansi_test.clj create mode 100644 test/tui/api_test.clj create mode 100644 test/tui/core_test.clj create mode 100644 test/tui/edge_cases_test.clj create mode 100644 test/tui/examples_test.clj create mode 100644 test/tui/input_test.clj create mode 100644 test/tui/render_test.clj create mode 100644 test/tui/simple_test.clj 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 0000000000000000000000000000000000000000..7f9a613d92020edb7c8cc83f892eb539d1ccbed8 GIT binary patch literal 20097 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 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)))))))