Files
Codeman/test/tui/tui-layout.test.ts
T
Codeman maintainer 74fe2cad9f feat: add the TUI responsive layout math
Below 72 columns the preview pane is dropped and rows take two lines, the
constraint the `sc` chooser was built around and the reason it is still usable
on a phone; above it a clamped sidebar carries the list and the preview takes
the rest.

Every region is clamped to a non-negative size, so a 5x5 terminal degrades to a
header instead of handing the renderer negative widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:13:57 +02:00

134 lines
4.8 KiB
TypeScript

/**
* @fileoverview Unit tests for the responsive layout math.
*
* Two things are pinned here because the renderer trusts them blindly: the
* regions tile the screen exactly (no gaps, no overlap, full coverage), and no
* region is ever negative, however small or absurd the terminal gets.
*/
import { describe, it, expect } from 'vitest';
import {
computeLayout,
needsBanner,
NARROW_BREAKPOINT,
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
type TuiLayout,
} from '../../src/tui/tui-layout.js';
function rects(layout: TuiLayout) {
return [layout.header, layout.banner, layout.body, layout.list, layout.divider, layout.preview, layout.footer];
}
function expectSane(layout: TuiLayout): void {
for (const rect of rects(layout)) {
if (!rect) continue;
expect(rect.width).toBeGreaterThanOrEqual(0);
expect(rect.height).toBeGreaterThanOrEqual(0);
expect(rect.row).toBeGreaterThanOrEqual(1);
expect(rect.col).toBeGreaterThanOrEqual(1);
expect(rect.col + rect.width - 1).toBeLessThanOrEqual(Math.max(1, layout.cols));
if (rect.height > 0) expect(rect.row + rect.height - 1).toBeLessThanOrEqual(layout.rows);
}
expect(layout.header.height + layout.body.height + layout.footer.height).toBe(layout.rows);
}
describe('computeLayout', () => {
it('stacks header, body and footer with no gap', () => {
const layout = computeLayout(100, 30);
expect(layout.header).toEqual({ row: 1, col: 1, width: 100, height: 1 });
expect(layout.body.row).toBe(2);
expect(layout.body.height).toBe(28);
expect(layout.footer).toEqual({ row: 30, col: 1, width: 100, height: 1 });
expectSane(layout);
});
it('splits a wide body into sidebar, divider and preview covering every column', () => {
const layout = computeLayout(100, 30);
expect(layout.narrow).toBe(false);
expect(layout.rowHeight).toBe(1);
expect(layout.list.width).toBe(36);
expect(layout.divider).toEqual({ row: 2, col: 37, width: 1, height: 28 });
expect(layout.preview).toEqual({ row: 2, col: 38, width: 63, height: 28 });
expect(layout.list.width + 1 + (layout.preview?.width ?? 0)).toBe(layout.cols);
});
it('clamps the sidebar at both ends', () => {
expect(computeLayout(NARROW_BREAKPOINT, 30).list.width).toBe(SIDEBAR_MIN_WIDTH);
expect(computeLayout(200, 30).list.width).toBe(SIDEBAR_MAX_WIDTH);
expect(computeLayout(400, 30).list.width).toBe(SIDEBAR_MAX_WIDTH);
});
it('drops the preview and doubles the row height below the breakpoint', () => {
const narrow = computeLayout(NARROW_BREAKPOINT - 1, 24);
expect(narrow.narrow).toBe(true);
expect(narrow.rowHeight).toBe(2);
expect(narrow.preview).toBeNull();
expect(narrow.divider).toBeNull();
expect(narrow.list.width).toBe(NARROW_BREAKPOINT - 1);
expect(computeLayout(NARROW_BREAKPOINT, 24).narrow).toBe(false);
expectSane(narrow);
});
it('carves the banner out of the top of the body when asked', () => {
const plain = computeLayout(100, 30);
const banner = computeLayout(100, 30, { banner: true });
expect(plain.banner).toBeNull();
expect(banner.banner).toEqual({ row: 2, col: 1, width: 100, height: 1 });
expect(banner.body.height).toBe(plain.body.height);
expect(banner.list.row).toBe(plain.list.row + 1);
expect(banner.list.height).toBe(plain.list.height - 1);
expectSane(banner);
});
it('degrades on a tiny terminal without producing negative sizes', () => {
for (const [cols, rows] of [
[5, 5],
[1, 1],
[1, 2],
[3, 3],
[80, 2],
[80, 1],
] as const) {
const layout = computeLayout(cols, rows, { banner: true });
expectSane(layout);
// The shape is width-driven, never height-driven: an 80x1 terminal is
// still a wide one, it just has nowhere to put the body.
expect(layout.narrow).toBe(cols < NARROW_BREAKPOINT);
}
const one = computeLayout(1, 1);
expect(one.body.height).toBe(0);
expect(one.footer.height).toBe(0);
const two = computeLayout(40, 2);
expect(two.body.height).toBe(0);
expect(two.banner).toBeNull();
expect(two.footer.height).toBe(1);
});
it('clamps nonsense dimensions to one cell', () => {
for (const [cols, rows] of [
[0, 0],
[-10, -10],
[Number.NaN, Number.NaN],
] as const) {
const layout = computeLayout(cols, rows);
expect(layout.cols).toBe(1);
expect(layout.rows).toBe(1);
expectSane(layout);
}
});
it('floors fractional dimensions', () => {
expect(computeLayout(100.9, 30.9).cols).toBe(100);
expect(computeLayout(100.9, 30.9).rows).toBe(30);
});
});
describe('needsBanner', () => {
it('is the caller-side rule for reserving the banner row', () => {
expect(needsBanner('connected')).toBe(false);
expect(needsBanner('reconnecting')).toBe(true);
expect(needsBanner('degraded')).toBe(true);
expect(needsBanner('down')).toBe(true);
});
});