(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)))))))