// @vitest-environment happy-dom import { describe, it, expect, vi } from 'vitest'; // office.js only needs switchToSession from terminal.js, which pulls in xterm. vi.mock('../public/modules/terminal.js', () => ({ switchToSession: vi.fn(), restartAgent: vi.fn(), restartPending: () => false })); // The click wiring is the feature: a board click must reach showJobBoard(). The // rest of jobs.js stays real so COLUMNS below is still the production list. vi.mock('../public/modules/jobs.js', async (actual) => ({ ...(await actual()), showJobBoard: vi.fn(), })); const { computeBoardLayout, BOARD_TITLES, BOARD_STATES, countBoardJobs, boardPostPlan, hitJobBoard, setupOfficeClick } = await import('../public/modules/office.js'); const { COLUMNS, showJobBoard } = await import('../public/modules/jobs.js'); const { JOB_STATES, STATE_LABELS } = await import('../lib/jobs.js'); const Z = 3; // The board's drawn box, matching drawJobBoardEasels: the left edge is floored, // and the drop shadow in drawWhiteboard reaches one Z-unit further right. function drawnBox(section, board) { const left = section.centerX - Math.floor(board.boardW / 2); return { left, right: left + board.boardW, shadowRight: left + board.boardW + Z }; } // The job boards stand in the three wall sections between the windows. Their // geometry is pure arithmetic over the panel width, so it is checked here // rather than by eyeballing the canvas. describe('job board placement', () => { // Includes the band where the boards first appear and are at minimum size. const widths = [200, 240, 252, 270, 288, 306, 320, 400, 480, 640, 900, 1280, 2000]; it('keeps every board, drop shadow included, inside its own wall section', () => { for (const w of widths) { const board = computeBoardLayout(w); if (!board.visible) continue; for (const section of board.sections) { const box = drawnBox(section, board); expect(box.left, `panel ${w}`).toBeGreaterThanOrEqual(section.left); expect(box.shadowRight, `panel ${w}`).toBeLessThanOrEqual(section.right); } } }); it('centres the board frame on its stand', () => { for (const w of widths) { const board = computeBoardLayout(w); if (!board.visible) continue; for (const section of board.sections) { const box = drawnBox(section, board); // The stand's legs, brace and shadow are all centred on section.centerX. expect((box.left + box.right) / 2, `panel ${w}`).toBe(section.centerX); } } }); it('stands almost against the wall, on the floor, clear of the desk grid', () => { for (const w of widths) { const board = computeBoardLayout(w); if (!board.visible) continue; // Feet on the floor (below the baseboard), no further out than the // plant pots' fronts (6 Z deep): the boards belong against the wall, // not wandering out into the room. expect(board.footY, `panel ${w}`).toBeGreaterThan(board.wallBottom); expect(board.footY - board.wallBottom, `panel ${w}`).toBeLessThanOrEqual(6 * Z); // The frame rises up the wall but never above the ceiling line. expect(board.boardTop, `panel ${w}`).toBeGreaterThanOrEqual(0); expect(board.footY, `panel ${w}`).toBeLessThan(board.floorTop); expect(board.frameBottom, `panel ${w}`).toBeLessThan(board.footY); } }); it('draws nothing rather than smudges on a panel too narrow for a board', () => { expect(computeBoardLayout(160).visible).toBe(false); expect(computeBoardLayout(900).visible).toBe(true); }); }); // The three whiteboards are the real board's columns, in order. The column // list exists three times (server states, board tab labels, canvas titles) // and nothing imports across them, so this is what keeps them in step. // `done` has no column (a merged card leaves the board), so no whiteboard. describe('job board titles', () => { it('are the board tab column labels, shouted', () => { expect(BOARD_TITLES).toEqual(COLUMNS.map((c) => c.label.toUpperCase())); }); it('cover every job state except done, in order', () => { expect(COLUMNS.map((c) => c.state)).toEqual(JOB_STATES.filter((s) => s !== 'done')); }); it('read the same as the labels the server writes out', () => { // The browser cannot import lib/jobs.js (only public/ is served), so the // column labels exist twice: here for the board, and as STATE_LABELS for // whatever writes a card out as text — the MCP tools an agent reads. for (const c of COLUMNS) expect(STATE_LABELS[c.state]).toBe(c.label); }); }); // Each whiteboard pins one paper per real job in its column, so the canvas // mirrors the board tab's job counts instead of a fixed decoration. describe('job board post sync', () => { it('maps the same states as the board tab columns, in order', () => { expect(BOARD_STATES).toEqual(COLUMNS.map((c) => c.state)); }); it('counts jobs per column and ignores done, unknown, and malformed jobs', () => { const counts = countBoardJobs([ { state: 'todo' }, { state: 'todo' }, { state: 'in-progress' }, { state: 'review' }, { state: 'review' }, { state: 'review' }, { state: 'done' }, { state: 'bogus' }, {}, null, ]); expect(counts).toEqual([2, 1, 3]); }); it('accepts the production shape — a Map values() iterator', () => { const jobs = new Map([['job-1', { state: 'todo' }], ['job-2', { state: 'review' }]]); expect(countBoardJobs(jobs.values())).toEqual([1, 0, 1]); }); it('handles an empty board', () => { expect(countBoardJobs([])).toEqual([0, 0, 0]); expect(boardPostPlan(0)).toEqual({ shown: 0, overflow: 0 }); }); it('caps posts at the pin slots and reports the overflow', () => { // The cap itself: shown saturates while overflow picks up the rest. The // slot count is asserted indirectly so adding a slot doesn't break this. const cap = boardPostPlan(100).shown; expect(cap).toBeGreaterThan(0); expect(boardPostPlan(3)).toEqual({ shown: 3, overflow: 0 }); expect(boardPostPlan(cap)).toEqual({ shown: cap, overflow: 0 }); expect(boardPostPlan(cap + 1)).toEqual({ shown: cap, overflow: 1 }); expect(boardPostPlan(cap + 3)).toEqual({ shown: cap, overflow: 3 }); }); }); describe('clicking a board opens the Jobs tab', () => { const W = 1200; const board = computeBoardLayout(W); it('hits every painted board, frame and stand', () => { for (const { centerX } of board.sections) { expect(hitJobBoard(centerX, board.boardTop + 1, W)).toBe(true); expect(hitJobBoard(centerX, board.footY - 1, W)).toBe(true); // the stand } }); it('misses the wall above, the floor below and the gaps between', () => { const { centerX } = board.sections[0]; expect(hitJobBoard(centerX, board.boardTop - 1, W)).toBe(false); expect(hitJobBoard(centerX, board.footY + 1, W)).toBe(false); const gap = (board.sections[0].centerX + board.sections[1].centerX) / 2; expect(hitJobBoard(gap, board.boardTop + 1, W)).toBe(false); }); it('never hits when the panel is too narrow to draw a board', () => { const narrow = 120; expect(computeBoardLayout(narrow).visible).toBe(false); expect(hitJobBoard(60, 60, narrow)).toBe(false); }); }); describe('the canvas click reaches the board', () => { const W = 1200, H = 800; // A stand-in for the office canvas: setupOfficeClick only needs to register a // listener and measure the element. function fakeCanvas() { let handler; const canvas = { addEventListener: (_type, fn) => { handler = fn; }, getBoundingClientRect: () => ({ left: 0, top: 0, width: W, height: H }), }; vi.spyOn(document, 'getElementById').mockReturnValue(canvas); setupOfficeClick(); return (x, y) => handler({ clientX: x, clientY: y }); } it('opens the Jobs tab on a board click and leaves it shut elsewhere', () => { showJobBoard.mockClear(); const click = fakeCanvas(); const board = computeBoardLayout(W); click(board.sections[1].centerX, board.boardTop + 4); expect(showJobBoard).toHaveBeenCalledTimes(1); click(board.sections[1].centerX, H - 4); // empty floor, no agents expect(showJobBoard).toHaveBeenCalledTimes(1); document.getElementById.mockRestore(); }); });