#+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.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