472 lines
19 KiB
Org Mode
472 lines
19 KiB
Org Mode
#+TITLE: Integration Tests for cl-tty
|
|
#+STARTUP: content
|
|
#+FILETAGS: :cl-tty:test:
|
|
|
|
* Overview
|
|
|
|
These integration tests compose all major cl-tty components through the
|
|
framebuffer backend and verify cell-level output. Instead of mocking
|
|
individual components, each test creates a real ~framebuffer-backend~,
|
|
plumbs components into it, and inspects the resulting cell grid.
|
|
|
|
This gives us confidence that:
|
|
|
|
- Components render the expected characters at the expected positions.
|
|
- Layout coordinates are applied correctly before rendering.
|
|
- Scroll offsets, cursor positions, dialog stacks, and toast messages
|
|
all compose correctly on a single framebuffer.
|
|
- The full ~render-screen~ pipeline works end-to-end.
|
|
|
|
The framebuffer backend uses ASCII box-drawing characters (+, -, |) so
|
|
tests remain portable across terminals.
|
|
|
|
** Test layout
|
|
|
|
The file is structured as:
|
|
|
|
1. Package definition, suite definition, and helper functions (first
|
|
block — overwrites target).
|
|
2. Individual test functions (each in its own block — appends target).
|
|
|
|
* Package and Suite
|
|
|
|
The integration tests live in their own package ~cl-tty-integration-test~
|
|
to avoid polluting the component namespaces. We use ~fiveam~ for the test
|
|
framework with ~def-suite~ and ~in-suite~ so all tests belong to
|
|
~integration-suite~.
|
|
|
|
The run-all-tests.lisp loader references this suite by name
|
|
(~\"INTEGRATION-SUITE\"~) and looks it up via ~find-symbol~ in the
|
|
package, so the symbol must be interned and accessible.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
;;; integration-tests.lisp — Full pipeline integration tests for cl-tty
|
|
;;;
|
|
;;; Composes all major components through the rendering pipeline onto a
|
|
;;; framebuffer backend and verifies cell-level output.
|
|
;;;
|
|
;;; This file is tangled from org/integration-tests.org — do not edit directly.
|
|
|
|
(defpackage :cl-tty-integration-test
|
|
(:use :cl :fiveam
|
|
:cl-tty.backend :cl-tty.box :cl-tty.layout
|
|
:cl-tty.input :cl-tty.select :cl-tty.container
|
|
:cl-tty.rendering :cl-tty.dialog))
|
|
|
|
(in-package :cl-tty-integration-test)
|
|
|
|
(def-suite integration-suite
|
|
:description "Full pipeline integration tests for cl-tty")
|
|
|
|
(in-suite integration-suite)
|
|
#+END_SRC
|
|
|
|
* Helper Functions
|
|
|
|
These helpers extract and search text from the framebuffer cell grid.
|
|
They are shared by all tests and avoid duplicating cell-access logic.
|
|
|
|
** ~fb-string~
|
|
|
|
Reads a string of ~len~ characters from framebuffer ~fb~ starting at
|
|
coordinates ~(x, y)~. This is the primitive all other helpers build on.
|
|
|
|
The framebuffer stores cells in a 2D array indexed as ~(aref cells y x)~.
|
|
Cells are structs with a ~cell-char~ slot holding the character. We
|
|
iterate horizontally and collect each ~cell-char~ into a string.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(defun fb-string (fb x y &optional (len 1))
|
|
"Read a string of LEN characters from framebuffer FB starting at (X,Y)."
|
|
(let* ((cells (fb-framebuffer fb))
|
|
(w (framebuffer-width cells))
|
|
(h (framebuffer-height cells)))
|
|
(declare (ignore h))
|
|
(with-output-to-string (s)
|
|
(loop for i from 0 below len
|
|
for cx = (+ x i)
|
|
while (< cx w)
|
|
do (princ (cell-char (aref cells y cx)) s)))))
|
|
#+END_SRC
|
|
|
|
** ~fb-lines~
|
|
|
|
Extracts all rows from the framebuffer as a list of strings. Each row is
|
|
the full width of the framebuffer converted via ~fb-string~. Optional
|
|
~start-row~ and ~end-row~ keywords let callers inspect a sub-region.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(defun fb-lines (fb &key (start-row 0) (end-row nil))
|
|
"Extract all lines from framebuffer FB as a list of strings."
|
|
(let* ((cells (fb-framebuffer fb))
|
|
(w (framebuffer-width cells))
|
|
(h (framebuffer-height cells))
|
|
(max-row (min (or end-row h) h)))
|
|
(declare (ignore w))
|
|
(loop for y from start-row below max-row
|
|
collect (fb-string fb 0 y (framebuffer-width cells)))))
|
|
#+END_SRC
|
|
|
|
** ~fb-contains~
|
|
|
|
Returns ~T~ if the text content of the framebuffer contains ~text~
|
|
anywhere, using case-insensitive comparison. Concatenates all lines with
|
|
newlines and runs ~search~.
|
|
|
|
This is the most commonly used assertion helper — it lets tests check for
|
|
the presence of rendered text without specifying exact coordinates.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(defun fb-contains (fb text)
|
|
"Return T if framebuffer FB contains TEXT anywhere."
|
|
(let ((all-text (format nil "~{~a~^~%~}" (fb-lines fb))))
|
|
(search text all-text :test #'char-equal)))
|
|
#+END_SRC
|
|
|
|
* Individual Tests
|
|
|
|
** Box with title renders correctly
|
|
|
|
A ~Box~ with a ~:single~ border style draws ASCII border characters
|
|
(+, -, |) and paints the title text at the top border. This test verifies
|
|
both the structural border characters and the title positioning.
|
|
|
|
The title is rendered starting at column 2 of row 1 (just inside the
|
|
top border). We check ~fb-string~ at those exact coordinates for the
|
|
title text, and ~fb-contains~ for the border characters.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test box-title-renders-on-fb
|
|
"A Box with a title draws border and title text on framebuffer."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 10))
|
|
(bx (make-box :border-style :single :title "My Box" :width 40 :height 10)))
|
|
(compute-layout (box-layout-node bx) 40 10)
|
|
(render-box bx fb)
|
|
;; Framebuffer uses ASCII border chars (+, -, |)
|
|
(is-true (fb-contains fb "My Box") "title text appears")
|
|
(is-true (fb-contains fb "+") "top-left corner appears")
|
|
(is-true (fb-contains fb "-") "horizontal border appears")
|
|
;; Check the title at row 0, col 2
|
|
(is (equal "My Box" (fb-string fb 2 1 6)) "title at correct position")))
|
|
#+END_SRC
|
|
|
|
** Text component with word-wrap
|
|
|
|
The ~Text~ component word-wraps content to fit within a given width and
|
|
height. This test renders a sentence longer than the framebuffer width
|
|
and verifies that individual words break across lines as expected.
|
|
|
|
Word-wrap mode ~:word~ preserves word boundaries — it only wraps between
|
|
words, never in the middle of one. The framebuffer is 20 columns wide, so
|
|
each row holds roughly 2-3 words.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test text-component-on-fb
|
|
"Text component renders word-wrapped content on framebuffer."
|
|
(let* ((fb (make-framebuffer-backend :width 20 :height 6))
|
|
(tx (make-text "Hello brave new world of terminal UI"
|
|
:wrap-mode :word :width 20 :height 4)))
|
|
(compute-layout (text-layout-node tx) 20 4)
|
|
(render-text tx fb)
|
|
(is-true (fb-contains fb "Hello") "first word appears")
|
|
(is-true (fb-contains fb "brave") "second word appears")
|
|
(is-true (fb-contains fb "world") "third word wraps")))
|
|
#+END_SRC
|
|
|
|
** TextInput with value
|
|
|
|
~TextInput~ renders its current value as plain text and draws a cursor
|
|
block (~█~) at the cursor position. The cursor character is a full block
|
|
(U+2588) — a Unicode character that renders as a solid rectangle in most
|
|
terminals.
|
|
|
|
This test checks the value string at row 0 and then directly inspects the
|
|
cell at the cursor position to confirm the block character is present.
|
|
Direct cell access (~aref~ on the framebuffer array) is necessary because
|
|
the cursor block is a single character that ~fb-contains~ could match
|
|
ambiguously.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test textinput-value-on-fb
|
|
"TextInput renders its value and cursor on framebuffer."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 3))
|
|
(ti (make-text-input :value "hello world" :cursor 11)))
|
|
(setf (text-input-layout-node ti)
|
|
(make-layout-node :width 40 :height 1))
|
|
(compute-layout (text-input-layout-node ti) 40 1)
|
|
(render ti fb)
|
|
;; Verify value via direct cell inspection
|
|
(is (equal "hello world" (fb-string fb 0 0 11)) "value appears at row 0")
|
|
;; Check cursor block at position 11
|
|
(let* ((cells (fb-framebuffer fb))
|
|
(cursor-char (cell-char (aref cells 0 11))))
|
|
(is (eql #\█ cursor-char) "cursor block is drawn at position 11"))))
|
|
#+END_SRC
|
|
|
|
** TextInput empty shows placeholder
|
|
|
|
When ~TextInput~ has an empty value (~\"\"~) and a ~placeholder~ is set,
|
|
the placeholder text is rendered in place of the value. This provides
|
|
visual guidance to the user about what to type.
|
|
|
|
The placeholder must disappear once a value is set — that behavior is
|
|
tested indirectly here by verifying the placeholder text appears on an
|
|
empty input.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test textinput-placeholder-on-fb
|
|
"TextInput with empty value shows placeholder text."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 3))
|
|
(ti (make-text-input :value "" :placeholder "Type here...")))
|
|
(setf (text-input-layout-node ti)
|
|
(make-layout-node :width 40 :height 1))
|
|
(compute-layout (text-input-layout-node ti) 40 1)
|
|
(render ti fb)
|
|
(is (equal "Type here..." (fb-string fb 0 0 12)) "placeholder appears at row 0")))
|
|
#+END_SRC
|
|
|
|
** ScrollBox with children
|
|
|
|
~ScrollBox~ is a container that renders a subset of its children based on
|
|
scroll offset. Children above the offset are clipped (scrolled out), and
|
|
only visible children appear in the viewport.
|
|
|
|
This test creates 8 text children (each one line tall) in a ScrollBox
|
|
with ~scroll-y=2~ and a viewport height of 8. Lines 1-2 should be
|
|
scrolled out, while Lines 3-8 should be visible. We check both presence
|
|
(visible lines) and absence (scrolled-out lines).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test scrollbox-children-on-fb
|
|
"ScrollBox renders visible children offset by scroll position."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 10))
|
|
(children nil))
|
|
;; Create 8 text children, each 1 line tall
|
|
(dotimes (i 8)
|
|
(let ((tx (make-text (format nil "Line ~D" (1+ i))
|
|
:wrap-mode :none :width 40 :height 1)))
|
|
(push tx children)))
|
|
(setf children (nreverse children))
|
|
(let ((sb (make-scroll-box :children children :scroll-y 2)))
|
|
;; Set scroll-box layout to 40x8 viewport using component-layout-node
|
|
(let ((ln (component-layout-node sb)))
|
|
(setf (layout-node-width ln) 40)
|
|
(setf (layout-node-height ln) 8))
|
|
;; Layout each child too
|
|
(dolist (c children)
|
|
(compute-layout (component-layout-node c) 40 1))
|
|
(render sb fb)
|
|
;; Because scroll-y=2, Line 1 and Line 2 are scrolled out
|
|
;; Line 3 should be first visible
|
|
(is-true (fb-contains fb "Line 3") "scroll-y=2 shows Line 3 first")
|
|
(is-true (fb-contains fb "Line 4") "Line 4 is visible")
|
|
(is-true (fb-contains fb "Line 5") "Line 5 is visible")
|
|
;; Line 1 and 2 should NOT be visible (scrolled out)
|
|
(is-false (fb-contains fb "Line 1") "Line 1 scrolled out")
|
|
(is-false (fb-contains fb "Line 2") "Line 2 scrolled out"))))
|
|
#+END_SRC
|
|
|
|
** Select renders options
|
|
|
|
~Select~ is a dropdown-like component that displays a list of options
|
|
with titles. This test verifies that all three option titles (\"Red\",
|
|
\"Green\", \"Blue\") appear on the framebuffer after rendering.
|
|
|
|
The ~make-select~ function takes a list of plists with ~:title~ and
|
|
~:value~ keys. The render method iterates over options and draws each
|
|
title.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test select-options-on-fb
|
|
"Select renders option titles on framebuffer."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 10))
|
|
(sel (make-select
|
|
:options '((:title "Red" :value :red)
|
|
(:title "Green" :value :green)
|
|
(:title "Blue" :value :blue)))))
|
|
(let ((ln (select-layout-node sel)))
|
|
(setf (layout-node-width ln) 40)
|
|
(setf (layout-node-height ln) 5))
|
|
(render sel fb)
|
|
(is-true (fb-contains fb "Red") "first option appears")
|
|
(is-true (fb-contains fb "Green") "second option appears")
|
|
(is-true (fb-contains fb "Blue") "third option appears")))
|
|
#+END_SRC
|
|
|
|
** Dialog renders with backdrop
|
|
|
|
~Dialog~ is a modal overlay component. When pushed onto the dialog stack,
|
|
rendering it draws a dimmed backdrop over the entire framebuffer and a
|
|
dialog panel (with border and title) centered in the viewport.
|
|
|
|
This test creates a dialog with title \"Confirm\", pushes it onto the
|
|
global stack, renders it, and checks for the title and ASCII border
|
|
characters. The backdrop is a dimming overlay applied across the full
|
|
framebuffer area.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test dialog-appears-on-fb
|
|
"Dialog renders a dimmed backdrop and dialog panel with title."
|
|
(let* ((fb (make-framebuffer-backend :width 80 :height 24))
|
|
(d (make-instance 'dialog :title "Confirm" :size :small)))
|
|
(push-dialog d)
|
|
(render-dialog d fb 80 24)
|
|
;; Dialog title appears somewhere in the output
|
|
(is-true (fb-contains fb "Confirm") "dialog title appears")
|
|
;; Dialog border (ASCII)
|
|
(is-true (fb-contains fb "+") "dialog border appears")
|
|
(is-true (fb-contains fb "|") "dialog vertical border appears")
|
|
;; Clean up
|
|
(pop-dialog)))
|
|
#+END_SRC
|
|
|
|
** Dialog push/pop with render
|
|
|
|
The dialog system maintains a stack (~*dialog-stack*~). When multiple
|
|
dialogs are pushed, only the topmost dialog is rendered. Popping a dialog
|
|
restores the previous one.
|
|
|
|
This test pushes two dialogs (\"Dialog One\" and \"Dialog Two\"),
|
|
verifies that only the top dialog (\"Dialog Two\") renders, then pops it
|
|
and verifies that \"Dialog One\" appears after clearing and re-rendering.
|
|
This exercises the full push-pop-render cycle.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test dialog-push-pop-render
|
|
"Dialog push/pop cycle works with rendering."
|
|
(let* ((fb (make-framebuffer-backend :width 80 :height 24))
|
|
(d1 (make-instance 'dialog :title "Dialog One"))
|
|
(d2 (make-instance 'dialog :title "Dialog Two")))
|
|
(push-dialog d1)
|
|
(push-dialog d2)
|
|
(render-dialog (first *dialog-stack*) fb 80 24)
|
|
(is-true (fb-contains fb "Dialog Two") "top dialog renders")
|
|
(pop-dialog)
|
|
(backend-clear fb)
|
|
(render-dialog (first *dialog-stack*) fb 80 24)
|
|
(is-true (fb-contains fb "Dialog One") "second dialog renders after pop")
|
|
(pop-dialog)))
|
|
#+END_SRC
|
|
|
|
** Toast renders
|
|
|
|
~Toast~ notifications are ephemeral messages that appear at the bottom of
|
|
the screen with a colored background. They are managed via ~*toasts*~, a
|
|
list of active toasts.
|
|
|
|
This test creates a toast with variant ~:info~, renders the first toast
|
|
in the list, verifies the message text appears, and then dismisses it to
|
|
clean up. The ~duration~ is set to 0 so the toast does not auto-dismiss
|
|
during the test.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test toast-appears-on-fb
|
|
"Toast notification renders with colored background."
|
|
(let* ((fb (make-framebuffer-backend :width 80 :height 24)))
|
|
(toast "Hello from toast!" :variant :info :duration 0)
|
|
(render-toast (first *toasts*) fb 80)
|
|
(is-true (fb-contains fb "Hello from toast!") "toast message appears")
|
|
(dismiss-toast (first *toasts*))))
|
|
#+END_SRC
|
|
|
|
** render-screen pipeline
|
|
|
|
~render-screen~ is the top-level entry point for the rendering pipeline.
|
|
It takes a component tree root and a backend, performs layout computation
|
|
(if needed), and renders all components recursively.
|
|
|
|
This test creates a simple tree with a single Box, calls
|
|
~render-screen~, and verifies that both the title and border characters
|
|
appear. This validates that the pipeline dispatches correctly from root
|
|
through the component hierarchy.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test render-screen-pipeline
|
|
"render-screen processes a component tree through the full pipeline."
|
|
(let* ((fb (make-framebuffer-backend :width 40 :height 12))
|
|
(root (make-box :border-style :single :title "Root"
|
|
:width 40 :height 12)))
|
|
(render-screen root fb)
|
|
(is-true (fb-contains fb "Root") "title renders via render-screen")
|
|
;; Border characters (ASCII on framebuffer)
|
|
(is-true (fb-contains fb "+") "border renders")))
|
|
#+END_SRC
|
|
|
|
** Full composition via framebuffer
|
|
|
|
The ultimate integration test: compose all major components (Box, Text,
|
|
TextInput, Select) on a single framebuffer at specific positions and
|
|
verify everything renders correctly.
|
|
|
|
The layout is a 60x24 framebuffer with:
|
|
|
|
- A Box titled \"Dashboard\" as the outer container.
|
|
- A Text component with welcome message at (2, 2).
|
|
- A TextInput with value \"search query\" and cursor at position 12,
|
|
positioned at (2, 6).
|
|
- A Select with three options positioned at (2, 8).
|
|
|
|
Each component is positioned manually via ~layout-node-x~ and
|
|
~layout-node-y~ to simulate a composed screen. All components must coexist
|
|
without overwriting each other's output.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/integration-tests.lisp
|
|
(test full-composition-via-fb
|
|
"All components compose correctly on a single framebuffer."
|
|
(let* ((fb (make-framebuffer-backend :width 60 :height 24)))
|
|
;;
|
|
;; 1. Box with title at top
|
|
;;
|
|
(let ((bx (make-box :border-style :single :title "Dashboard"
|
|
:width 60 :height 24)))
|
|
(compute-layout (box-layout-node bx) 60 24)
|
|
(render-box bx fb))
|
|
|
|
;;
|
|
;; 2. Text content inside
|
|
;;
|
|
(let ((tx (make-text "Welcome to the dashboard."
|
|
:wrap-mode :word :width 56 :height 3)))
|
|
(setf (layout-node-x (text-layout-node tx)) 2)
|
|
(setf (layout-node-y (text-layout-node tx)) 2)
|
|
(compute-layout (text-layout-node tx) 56 3)
|
|
(render-text tx fb))
|
|
|
|
;;
|
|
;; 3. TextInput
|
|
;;
|
|
(let ((ti (make-text-input :value "search query" :cursor 12)))
|
|
(setf (text-input-layout-node ti) (make-layout-node))
|
|
(setf (layout-node-x (text-input-layout-node ti)) 2)
|
|
(setf (layout-node-y (text-input-layout-node ti)) 6)
|
|
(setf (layout-node-width (text-input-layout-node ti)) 56)
|
|
(setf (layout-node-height (text-input-layout-node ti)) 1)
|
|
(render ti fb))
|
|
|
|
;;
|
|
;; 4. Select options
|
|
;;
|
|
(let ((sel (make-select
|
|
:options '((:title "Option A" :value :a)
|
|
(:title "Option B" :value :b)
|
|
(:title "Option C" :value :c)))))
|
|
(setf (select-layout-node sel) (make-layout-node))
|
|
(setf (layout-node-x (select-layout-node sel)) 2)
|
|
(setf (layout-node-y (select-layout-node sel)) 8)
|
|
(setf (layout-node-width (select-layout-node sel)) 56)
|
|
(setf (layout-node-height (select-layout-node sel)) 3)
|
|
(render sel fb))
|
|
|
|
;;
|
|
;; Verifications
|
|
;;
|
|
(is-true (fb-contains fb "Dashboard") "box title appears")
|
|
(is-true (fb-contains fb "Welcome") "text content appears")
|
|
;; Check TextInput value at its position
|
|
(is (equal "search query" (fb-string fb 2 6 12)) "TextInput value at row 6")
|
|
;; Check Select options at their positions
|
|
(is-true (fb-contains fb "Option A") "Select option A appears")
|
|
(is-true (fb-contains fb "Option B") "Select option B appears")
|
|
(is-true (fb-contains fb "Option C") "Select option C appears")))
|
|
#+END_SRC
|