test/client-office-boards.test.js
Rama main · réplica de lectura del repositorio original.
// @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();
});
});