init
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# VHS E2E test for Babashka counter
|
||||
Output test/e2e/output/bb_counter.ascii
|
||||
|
||||
Require bb
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 800
|
||||
Set Height 400
|
||||
|
||||
Type "bb counter"
|
||||
Enter
|
||||
Sleep 3s
|
||||
|
||||
Type "k"
|
||||
Sleep 500ms
|
||||
Type "q"
|
||||
Sleep 1s
|
||||
@@ -0,0 +1,46 @@
|
||||
# VHS E2E test for counter example
|
||||
# Tests basic Elm architecture flow: render → input → update → render
|
||||
|
||||
Output test/e2e/output/counter.gif
|
||||
Output test/e2e/output/counter.ascii
|
||||
|
||||
Require clojure
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 800
|
||||
Set Height 400
|
||||
Set Framerate 10
|
||||
|
||||
# Run the counter example
|
||||
Type "clojure -A:dev -M -m examples.counter"
|
||||
Enter
|
||||
Sleep 2s
|
||||
|
||||
# Test increment with 'k' key
|
||||
Type "k"
|
||||
Sleep 500ms
|
||||
Type "k"
|
||||
Sleep 500ms
|
||||
Type "k"
|
||||
Sleep 500ms
|
||||
|
||||
# Test decrement with 'j' key
|
||||
Type "j"
|
||||
Sleep 500ms
|
||||
|
||||
# Test up arrow
|
||||
Up
|
||||
Sleep 500ms
|
||||
|
||||
# Test down arrow
|
||||
Down
|
||||
Sleep 500ms
|
||||
|
||||
# Test reset with 'r'
|
||||
Type "r"
|
||||
Sleep 500ms
|
||||
|
||||
# Quit with 'q'
|
||||
Type "q"
|
||||
Sleep 1s
|
||||
@@ -0,0 +1,375 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
> bb counter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,629 @@
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
> clojure -A:dev -M -m examples.counter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
> clojure -A:dev -M -m examples.counter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 1 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 3 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 3 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 3 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 3 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 2 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
╭──────────╮
|
||||
│ Counter │
|
||||
│ │
|
||||
│ Count: 0 │
|
||||
╰──────────╯
|
||||
|
||||
j/k or up/do
|
||||
wn: change value
|
||||
|
||||
r: reset q: quit
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -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)))))
|
||||
@@ -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}])))))
|
||||
@@ -0,0 +1,173 @@
|
||||
(ns tui.core-test
|
||||
"Integration tests for the TUI engine.
|
||||
Tests the update loop, command handling, and full render pipeline."
|
||||
(:require [clojure.test :refer [deftest testing is]]
|
||||
[clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]]
|
||||
[tui.core :as tui]
|
||||
[tui.render :as render]))
|
||||
|
||||
;; === Command Tests ===
|
||||
|
||||
(deftest quit-command-test
|
||||
(testing "quit command is correct vector"
|
||||
(is (= [:quit] tui/quit))))
|
||||
|
||||
(deftest tick-command-test
|
||||
(testing "tick creates correct command"
|
||||
(is (= [:tick 100] (tui/tick 100)))
|
||||
(is (= [:tick 1000] (tui/tick 1000)))))
|
||||
|
||||
(deftest batch-command-test
|
||||
(testing "batch combines commands"
|
||||
(is (= [:batch [:tick 100] [:quit]] (tui/batch (tui/tick 100) tui/quit))))
|
||||
|
||||
(testing "batch filters nil commands"
|
||||
(is (= [:batch [:tick 100]] (tui/batch nil (tui/tick 100) nil)))))
|
||||
|
||||
(deftest sequentially-command-test
|
||||
(testing "sequentially creates seq command"
|
||||
(is (= [:seq [:tick 100] [:quit]] (tui/sequentially (tui/tick 100) tui/quit))))
|
||||
|
||||
(testing "sequentially filters nil commands"
|
||||
(is (= [:seq [:tick 100]] (tui/sequentially nil (tui/tick 100) nil)))))
|
||||
|
||||
(deftest send-msg-command-test
|
||||
(testing "send-msg creates function that returns message"
|
||||
(let [cmd (tui/send-msg {:type :custom :data 42})]
|
||||
(is (fn? cmd))
|
||||
(is (= {:type :custom :data 42} (cmd))))))
|
||||
|
||||
;; === Key Matching Tests ===
|
||||
|
||||
(deftest key=-test
|
||||
(testing "key= delegates to input/key-match?"
|
||||
(is (tui/key= [:key {:char \q}] "q"))
|
||||
(is (tui/key= [:key :enter] :enter))
|
||||
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))
|
||||
(is (not (tui/key= [:key {:char \a}] "b")))))
|
||||
|
||||
(deftest key-str-test
|
||||
(testing "key-str converts key to string"
|
||||
(is (= "q" (tui/key-str [:key {:char \q}])))
|
||||
(is (= "enter" (tui/key-str [:key :enter])))))
|
||||
|
||||
;; === Full Pipeline Tests ===
|
||||
|
||||
(deftest render-pipeline-test
|
||||
(testing "model -> view -> render produces valid output"
|
||||
(let [model {:count 5}
|
||||
view (fn [{:keys [count]}]
|
||||
[:col
|
||||
[:text {:bold true} "Counter"]
|
||||
[:text (str "Count: " count)]])
|
||||
rendered (render/render (view model))]
|
||||
(is (string? rendered))
|
||||
(is (clojure.string/includes? rendered "Counter"))
|
||||
(is (clojure.string/includes? rendered "Count: 5")))))
|
||||
|
||||
(deftest update-function-contract-test
|
||||
(testing "update function returns [model cmd] tuple"
|
||||
(let [update-fn (fn [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
(tui/key= msg :up) [(update model :n inc) nil]
|
||||
:else [model nil]))
|
||||
model {:n 0}]
|
||||
|
||||
;; Test quit returns command
|
||||
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
|
||||
(is (= model new-model))
|
||||
(is (= [:quit] cmd)))
|
||||
|
||||
;; Test up returns updated model
|
||||
(let [[new-model cmd] (update-fn model [:key :up])]
|
||||
(is (= {:n 1} new-model))
|
||||
(is (nil? cmd)))
|
||||
|
||||
;; Test unknown key returns model unchanged
|
||||
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
|
||||
(is (= model new-model))
|
||||
(is (nil? cmd))))))
|
||||
|
||||
;; === Command Execution Tests ===
|
||||
;; These test the internal command execution logic
|
||||
|
||||
(deftest execute-quit-command-test
|
||||
(testing "quit command puts :quit on channel"
|
||||
(let [msg-chan (chan 1)]
|
||||
(#'tui/execute-cmd! [:quit] msg-chan)
|
||||
(let [result (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 100) :timeout)]
|
||||
(is (= [:quit] result)))
|
||||
(close! msg-chan))))
|
||||
|
||||
(deftest execute-tick-command-test
|
||||
(testing "tick command sends :tick message after delay"
|
||||
(let [msg-chan (chan 1)]
|
||||
(#'tui/execute-cmd! [:tick 50] msg-chan)
|
||||
;; Should not receive immediately
|
||||
(let [immediate (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 10) :timeout)]
|
||||
(is (= :timeout immediate)))
|
||||
;; Should receive after delay
|
||||
(let [delayed (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 200) :timeout)]
|
||||
(is (vector? delayed))
|
||||
(is (= :tick (first delayed))))
|
||||
(close! msg-chan))))
|
||||
|
||||
(deftest execute-function-command-test
|
||||
(testing "function command executes and sends result"
|
||||
(let [msg-chan (chan 1)
|
||||
cmd (fn [] {:custom :message})]
|
||||
(#'tui/execute-cmd! cmd msg-chan)
|
||||
(let [result (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 100) :timeout)]
|
||||
(is (= {:custom :message} result)))
|
||||
(close! msg-chan))))
|
||||
|
||||
(deftest execute-batch-command-test
|
||||
(testing "batch executes multiple commands"
|
||||
(let [msg-chan (chan 10)]
|
||||
(#'tui/execute-cmd! [:batch
|
||||
(fn [] :msg1)
|
||||
(fn [] :msg2)]
|
||||
msg-chan)
|
||||
;; Give time for async execution
|
||||
(Thread/sleep 50)
|
||||
(let [results (loop [msgs []]
|
||||
(let [msg (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 10) nil)]
|
||||
(if msg
|
||||
(recur (conj msgs msg))
|
||||
msgs)))]
|
||||
(is (= #{:msg1 :msg2} (set results))))
|
||||
(close! msg-chan))))
|
||||
|
||||
(deftest execute-nil-command-test
|
||||
(testing "nil command does nothing"
|
||||
(let [msg-chan (chan 1)]
|
||||
(#'tui/execute-cmd! nil msg-chan)
|
||||
(let [result (alt!!
|
||||
msg-chan ([v] v)
|
||||
(timeout 50) :timeout)]
|
||||
(is (= :timeout result)))
|
||||
(close! msg-chan))))
|
||||
|
||||
;; === Defapp Macro Tests ===
|
||||
|
||||
(deftest defapp-macro-test
|
||||
(testing "defapp creates app map"
|
||||
(tui/defapp test-app
|
||||
:init {:count 0}
|
||||
:update (fn [m msg] [m nil])
|
||||
:view (fn [m] [:text "test"]))
|
||||
(is (map? test-app))
|
||||
(is (= {:count 0} (:init test-app)))
|
||||
(is (fn? (:update test-app)))
|
||||
(is (fn? (:view test-app)))))
|
||||
@@ -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")))))
|
||||
@@ -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"))))))
|
||||
@@ -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)))))
|
||||
@@ -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")))))
|
||||
@@ -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)))))))
|
||||
Reference in New Issue
Block a user