Files
cl-tty/org/integration-tests.org

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