/** * Render and mouse tests against a real (in-memory) renderer. * * The mouse cases matter most: pointer-to-cell mapping is the one bit of * coordinate arithmetic left in the UI, and it is invisible until someone * clicks the wrong tile over SSH. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" import { sampleDoc } from "./fixtures.ts" import { Store, type Screen } from "./state.ts" import { UI } from "./ui.ts" let setup: TestRendererSetup let store: Store let ui: UI /** Grid geometry, mirrored from ui.ts so tests fail loudly if it drifts. */ const CELL_H = 4 const CELL_W_MAX = 16 /** The mount box has padding 1, so content starts one row/column in. */ const PAD = 1 beforeEach(async () => { // 96 wide / 4 columns puts cellW at the 16-column cap, keeping the maths here // simple and matching what the preview script shows. setup = await createTestRenderer({ width: 96, height: 34 }) store = new Store() store.hydrate(sampleDoc()) ui = new UI(setup.renderer, store) store.subscribe(() => ui.render()) ui.render() }) afterEach(() => setup.renderer.destroy()) async function frame(): Promise { ui.render() await setup.flush() return setup.captureCharFrame() } /** * Reads store.screen without letting TypeScript narrow it to whatever literal * the test last assigned, so a screen change can be asserted. */ function currentScreen(): Screen { return store.screen } /** Centre of a grid cell in absolute terminal coordinates. */ function cellCentre(col: number, row: number): [x: number, y: number] { const titleRows = 2 // title line plus its bottom margin return [ PAD + col * CELL_W_MAX + Math.floor(CELL_W_MAX / 2), PAD + titleRows + row * CELL_H + Math.floor(CELL_H / 2), ] } describe("menu", () => { test("lists every entry and marks a live daemon", async () => { const out = await frame() expect(out).toContain("rtsp-streamer configurator") expect(out).toContain("Cameras") expect(out).toContain("Discover + enable RTSP (hi+lo)") expect(out).toContain("daemon live") }) test("shows an unsaved-changes hint once something is edited", async () => { expect(await frame()).not.toContain("unsaved changes") store.setActiveLayout(1) expect(await frame()).toContain("unsaved changes") }) }) describe("cameras", () => { test("shows available qualities, disabled state, and manual URLs", async () => { store.screen = "cameras" const out = await frame() expect(out).toContain("Driveway") expect(out).toContain("[high,low]") expect(out).toContain("[disabled]") expect(out).toContain("rtsp://10.0.0.9:554/stream1") }) }) describe("layouts", () => { test("marks the active layout and counts cameras", async () => { store.screen = "layouts" const out = await frame() expect(out).toContain("main-plus") expect(out).toContain("3 cameras") expect(out).toContain("active") }) }) describe("grid editor", () => { beforeEach(() => store.openEditor(0)) // main-plus, 4x3 test("titles each tile and notes its span and quality", async () => { const out = await frame() expect(out).toContain('Edit "main-plus" — 4x3 grid, 3/16 cameras') expect(out).toContain("Driveway") expect(out).toContain("3x2 hi") // the hero tile's span and pinned quality expect(out).toContain("auto") // Back Yard, unpinned }) test("a spanning tile is drawn as one box, not repeated per cell", async () => { const out = await frame() // "Driveway" spans 3x2 but is titled once. expect(out.split("Driveway").length - 1).toBe(1) }) test("resizing a tile is reflected in the next frame", async () => { store.setCell(3, 0) // Front Door, 1x1 store.resizeTile(0, 1) expect(await frame()).toContain("1x2 lo") }) test("the grid shrinks to fit a narrow terminal", async () => { setup.resize(50, 34) const out = await frame() // 4 columns must still fit within the width rather than overflowing. for (const line of out.split("\n")) expect(line.trimEnd().length).toBeLessThanOrEqual(50) }) }) describe("mouse", () => { test("clicking a list row selects and activates it", async () => { store.screen = "layouts" await frame() // Row 1 of the list: title plus its margin (2 rows), then one row each. await setup.mockMouse.click(PAD + 4, PAD + 2 + 1) // Activating resets `cursor` for the new screen, so the layout that was // opened is what identifies the row that got clicked. expect(currentScreen()).toBe("layoutEdit") expect(store.editLayout).toBe(1) expect(store.layout!.name).toBe("quad") }) test("clicking an empty cell opens the picker for that cell", async () => { store.openEditor(0) await frame() const [x, y] = cellCentre(3, 1) // the empty cell await setup.mockMouse.click(x, y) expect(store.screen).toBe("cameraPicker") expect([store.edCol, store.edRow]).toEqual([3, 1]) }) test("clicking inside a spanning tile targets that tile", async () => { store.openEditor(0) await frame() const [x, y] = cellCentre(2, 1) // inside the 3x2 hero tile await setup.mockMouse.click(x, y) expect([store.edCol, store.edRow]).toEqual([2, 1]) }) test("dragging a tile resizes it instead of opening the picker", async () => { store.openEditor(1) // quad 2x2, one 1x1 tile at (0,0) await frame() const from = cellCentre(0, 0) const to = cellCentre(1, 1) await setup.mockMouse.drag(from[0], from[1], to[0], to[1]) expect(store.layout!.tiles![0]).toMatchObject({ colspan: 2, rowspan: 2 }) // A drag must not fall through to the click handler. expect(store.screen).toBe("layoutEdit") }) test("a click outside the grid does not move the cell cursor", async () => { store.openEditor(0) store.setCell(1, 1) await frame() await setup.mockMouse.click(90, 30) // help text area, past the grid expect([store.edCol, store.edRow]).toEqual([1, 1]) expect(store.screen).toBe("layoutEdit") }) }) describe("camera picker", () => { test("shows the grid for context and flags placed cameras", async () => { store.openEditor(0) store.setCell(3, 1) store.screen = "cameraPicker" const out = await frame() expect(out).toContain("cell (col 3, row 1)") expect(out).toContain("(empty)") expect(out).toContain("already placed") // Driveway et al are in this layout expect(out).not.toContain("Side Gate") // disabled }) })