Files
cl-tty/org/integration-tests.org
Amr Gharbeia ef613927e6 v1.0.0: merge container (scrollbox + tabbar) into cl-tty.box
Eliminates the cl-tty.container package by merging scrollbox and tabbar
components directly into cl-tty.box, where the component system lives.

Changes:
- added scrollbox/tabbar exports to cl-tty.box defpackage in package.org
- changed scrollbox.org in-package from cl-tty.container to cl-tty.box
- changed tabbar.org in-package from cl-tty.container to cl-tty.box
- tabbar's key-event-key references are qualified with cl-tty.input:
  (avoids circular :use dependency with cl-tty.input which :uses cl-tty.box)
- deleted container-package.org
- updated test packages, integration tests, scripts, ASDF
- all 14 test suites pass at 100%
2026-05-18 16:45:50 -04:00

19 KiB

Integration Tests for cl-tty

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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