// @vitest-environment happy-dom import { describe, it, expect, vi, afterEach } from 'vitest'; // canvasDom() writes document.body and the sprite harnesses stubGlobal('Image'); // neither is undone, so a test that needs NO #office-canvas (or unloaded // sprites) passes in file order and fails under --sequence.shuffle.tests. afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); // 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 })); const { entryRoute, corridorY, pointAlongPath, detectDispatches, approachX, wanderRoute, wanderSeats, chatSeats, computeDecorPlacement, computePodLayout, podRugRect, computeSpareDesks, computeConference, walkObstacles, CHAR_COL_SIT, CHAR_FRAME_W } = await import('../public/modules/office.js'); const FLOOR_TOP = (36 * 3 + 2 * 3) + 26 * 3, CHAR_W = 32, CHAR_H = 64; // A leg's swept area: the walker box dragged from one end to the other. const swept = l => ({ x: Math.min(l.x0, l.x1), y: Math.min(l.y0, l.y1), w: Math.abs(l.x1 - l.x0) + CHAR_W, h: Math.abs(l.y1 - l.y0) + CHAR_H, }); // The leg that crosses the room: the horizontal one running to the entrance. // A blocked descent adds a sidestep along the desk's own row, which is not it. const crossingLeg = p => p.legs.find(l => l.y0 === l.y1 && (l.x0 === 3 || l.x1 === 3)); const overlaps = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; describe('walk path', () => { it('walks from start to end, clamped at both ends', () => { const p = { from: { x: 100, y: 200 }, total: 180, legs: [{ x0: 100, y0: 200, x1: 40, y1: 200 }, { x0: 40, y0: 200, x1: 40, y1: 80 }] }; expect(pointAlongPath(p, -5)).toMatchObject({ x: 100, y: 200 }); expect(pointAlongPath(p, p.total + 5)).toMatchObject({ x: 40, y: 80 }); expect(pointAlongPath(p, 30)).toMatchObject({ x: 70, y: 200, dx: -1, dy: 0 }); expect(pointAlongPath(p, 90)).toMatchObject({ x: 40, y: 170, dx: 0, dy: -1 }); }); it('handles a zero-length path', () => { const p = { from: { x: 100, y: 200 }, legs: [], total: 0 }; expect(pointAlongPath(p, 10)).toMatchObject({ x: 100, y: 200 }); }); }); describe('corridor rows', () => { const H = 800; // A pod rug band and the chat furniture along the bottom: one clear row // between them, the slivers above and below too narrow to walk. const rug = { x: 100, y: 216, w: 400, h: 160 }; const chat = { x: 60, y: 660, w: 600, h: 130 }; const obstacles = [rug, chat]; it('picks a row clear of every obstacle, wide enough for the character', () => { for (const y of [200, 300, 500, 700, 795]) { const c = corridorY(obstacles, H, y); const box = { x: 0, y: c, w: 1000, h: CHAR_H }; for (const o of obstacles) expect(overlaps(box, o), `near ${y}`).toBe(false); expect(c).toBeGreaterThanOrEqual(FLOOR_TOP); expect(c + CHAR_H).toBeLessThanOrEqual(H); } }); it('picks the clear row nearest the target, above or below it', () => { const mid = [{ x: 0, y: 300, w: 500, h: 120 }]; expect(corridorY(mid, H, 250)).toBe(300 - CHAR_H); // the band above expect(corridorY(mid, H, 600)).toBe(600); // the band below // A target on the furniture itself takes the nearer side of it expect(corridorY(mid, H, 350)).toBe(420); }); it('takes the widest gap when none fits a whole character, feet on its floor', () => { // 40px above the chat, 54px below the rug: the walker stands in the wider // one with its feet at the bottom, overlapping the furniture above it. const tight = [{ x: 0, y: FLOOR_TOP + 40, w: 900, h: 200 }, { x: 0, y: FLOOR_TOP + 294, w: 900, h: H }]; expect(corridorY(tight, H, 500)).toBe(FLOOR_TOP + 294 - CHAR_H); // Never the bottom edge, which is where the chat furniture lives expect(corridorY(tight, H, 500)).not.toBe(H - CHAR_H); }); it('stands on the desk row when the floor is furniture end to end', () => { expect(corridorY([{ x: 0, y: 0, w: 900, h: H }], H, 400)).toBe(400); }); }); describe('walk in/out routing', () => { const W = 900, H = 800; const rug = { x: 100, y: 216, w: 400, h: 160 }; const chat = { x: 60, y: 660, w: 600, h: 130 }; const obstacles = [rug, chat]; const desk = { x: 300, y: 260 }; // on the rug, in the top row // No door: the entrance is the left end of whatever row the walker crosses. const entryFor = d => ({ x: 3, y: corridorY(obstacles, H, d.y) }); for (const inbound of [true, false]) { it(`walk-${inbound ? 'in' : 'out'} enters on the left edge at its corridor row`, () => { const p = entryRoute(desk, null, inbound, H, obstacles); const entry = entryFor(desk); const [start, end] = inbound ? [entry, desk] : [desk, entry]; expect(p.from).toEqual(start); const last = p.legs[p.legs.length - 1]; expect({ x: last.x1, y: last.y1 }).toEqual(end); // The corridor is the clear row below the rug, not the bottom edge the // old route used — that one ran straight through the chat furniture. const across = crossingLeg(p); expect(across.y0).toBe(corridorY(obstacles, H, desk.y)); expect(across.y0).not.toBe(H - CHAR_H); // Only the desk column leg touches anything, and only the desk's own rug for (const l of p.legs) { const isDeskColumn = l.x0 === desk.x && l.x1 === desk.x; for (const o of obstacles) { if (isDeskColumn && o === rug) continue; expect(overlaps(swept(l), o), `${inbound ? 'in' : 'out'} leg ${JSON.stringify(l)}`).toBe(false); } } }); } it('has no fixed door: the entrance rides the corridor and never runs the left strip', () => { const below = { x: 300, y: 600 }; // a desk under the rug band, on the far row const [a, b] = [desk, below].map(d => entryRoute(d, null, true, H, obstacles)); expect(a.from.x).toBe(b.from.x); // both come in at the left edge expect(a.from.y).not.toBe(b.from.y); // at their own row, not one door for (const p of [a, b]) { expect(p.from).toEqual(entryFor(p === a ? desk : below)); // Nothing walks up or down the left edge to reach the row any more expect(p.legs.some(l => l.x0 === l.x1 && l.x0 === p.from.x)).toBe(false); } }); it('drops down a clear aisle instead of stepping over the rug in between', () => { // Only one gap fits a character, and it is below the second rug: the old // route came straight down the desk column, over that rug. const top = { x: 100, y: 200, w: 400, h: 160 }; const lower = { x: 60, y: 380, w: 500, h: 160 }; const d = { x: 300, y: 240 }; // on the top rug for (const inbound of [true, false]) { const p = entryRoute(d, null, inbound, H, [top, lower]); for (const l of p.legs) expect(overlaps(swept(l), lower), `${inbound ? 'in' : 'out'} leg ${JSON.stringify(l)}`).toBe(false); } }); it('falls back to the desk column when no aisle is clear on both legs', () => { // A blocker sitting on the desk's own row and in every candidate column: // no sidestep is clean, so the route keeps the old straight-down column. const dest = { x: 100, y: 200, w: 400, h: 160 }; const across = { x: 290, y: 230, w: 60, h: 300 }; const d = { x: 300, y: 240 }; const p = entryRoute(d, null, false, H, [dest, across]); for (const l of p.legs) expect(l.x0 === d.x || l.y0 === l.y1).toBe(true); }); it('approachX finds a clear aisle even when the point sits off any obstacle', () => { // approachX's dest lookup can come up empty (the point has no rug of its // own) — the candidate list is then just the blockers' edges, no dest. const blocker = { x: 280, y: 250, w: 60, h: 100 }; const point = { x: 300, y: 100 }; expect(approachX([blocker], point, 400)).toBe(340); }); it('approachX never lands a candidate left of the entry edge', () => { // The nearest edge candidate can fall off the left of the floor (a wide // obstacle hugging x=0) — ENTRY_X filters it out before scoring, even // though it would otherwise win on distance. const blocker = { x: -2, y: 250, w: 100, h: 100 }; const point = { x: 5, y: 100 }; expect(approachX([blocker], point, 400)).toBe(98); }); it('the sidestep keeps off the colleagues sharing the pod rug', () => { // The rug is one rect over the whole pod, so it is the walker's own floor // and cannot block. The desks in it can, and must: with only the rug in // the list the nearest clear column is 168, and stepping there sweeps the // pod row straight across the colleague sitting at 116. const rug = { x: 100, y: 200, w: 400, h: 160 }; const mine = { x: 236, y: 216, w: 96, h: 108 }; const mate = { x: 116, y: 216, w: 96, h: 108 }; const lower = { x: 200, y: 380, w: 360, h: 160 }; const d = { x: 250, y: 240 }; const p = entryRoute(d, null, false, H, [rug, mine, mate, lower]); // A real detour (it goes the other way, to 560), not the straight-down // fallback: nothing on the route touches the colleague or the far rug. expect(p.legs.length).toBe(3); for (const l of p.legs) for (const [name, box] of [['colleague', mate], ['rug below', lower]]) expect(overlaps(swept(l), box), `${name}, leg ${JSON.stringify(l)}`).toBe(false); // The other half of that rule: the walker's OWN desk is floor it is // standing on, so with the way below clear it walks straight down rather // than sidestepping around its own chair. expect(approachX([rug, mine], d, 500)).toBe(d.x); }); it('never picks a column that hangs the walker off the right edge', () => { // The candidate edges come from the obstacles, so a rug reaching the panel // edge offers a column with no room for the 32px walker beside it. const rug = { x: 100, y: 200, w: 400, h: 160 }; const lower = { x: 60, y: 380, w: 480, h: 160 }; // ends at 540, panel is 560 const d = { x: 300, y: 240 }; expect(approachX([rug, lower], d, 600, 560) + CHAR_W).toBeLessThanOrEqual(560); expect(approachX([rug, lower], d, 600)).toBe(540); // unclamped, for contrast }); it('breaks a tie toward wherever the crossing leg is headed', () => { // Two clear columns the same distance out; the walker leaves at the left // edge, so stepping right means walking back over the same ground. const blocker = { x: 268, y: 300, w: 64, h: 100 }; const d = { x: 284, y: 100 }; expect(approachX([blocker], d, 500, Infinity, 3)).toBe(236); // toward the entrance expect(approachX([blocker], d, 500, Infinity, 900)).toBe(332); // toward a right-hand lane }); it('the wander route to a conference seat takes the same clear aisle', () => { // The descent to the table used the desk column too, with the same result: // straight over whatever sat between the desk and the conference corridor. const conf = { table: { x: 300, y: 500, w: 200, h: 100 } }; const blocker = { x: 60, y: 330, w: 500, h: 90 }; const desk = { x: 300, y: 240 }; const seat = { x: 250, y: 520, row: 99 }; // a side seat, not the head for (const toSeat of [true, false]) { const p = wanderRoute(desk, seat, conf, toSeat, [blocker]); for (const l of p.legs) expect(overlaps(swept(l), blocker), `${toSeat ? 'to' : 'from'} leg ${JSON.stringify(l)}`).toBe(false); } }); it('crosses on the desk row, not the bottom edge, when no row is clear', () => { const full = [{ x: 0, y: 0, w: W, h: H }]; const p = entryRoute(desk, null, false, H, full); const across = crossingLeg(p); expect(across.y0).toBe(desk.y); expect(across.y0).not.toBe(H - CHAR_H); // the chat furniture lives there }); }); describe('dispatch detection', () => { const prev = new Map([ ['j1', { state: 'todo', agentSessionId: null }], ['j2', { state: 'review', agentSessionId: 's2' }], ['j3', { state: 'in-progress', agentSessionId: 's3' }], ]); it('flags a todo job dispatched to a new agent, naming its column', () => { const out = detectDispatches(prev, [ { id: 'j1', state: 'in-progress', agentSessionId: 's-new' }, ]); expect(out).toEqual([{ jobId: 'j1', sessionId: 's-new', fromState: 'todo' }]); }); it('ignores a manual move that keeps the same agent', () => { const out = detectDispatches(prev, [ { id: 'j2', state: 'in-progress', agentSessionId: 's2' }, ]); expect(out).toEqual([]); }); it('ignores unchanged in-progress jobs, agentless ones, and null entries', () => { const out = detectDispatches(prev, [ { id: 'j3', state: 'in-progress', agentSessionId: 's3' }, { id: 'j4', state: 'in-progress', agentSessionId: null }, { id: 'j5', state: 'todo', agentSessionId: null }, null, ]); expect(out).toEqual([]); }); it('names the column a re-dispatched job came from', () => { const out = detectDispatches(prev, [ { id: 'j2', state: 'in-progress', agentSessionId: 's-new' }, ]); expect(out).toEqual([{ jobId: 'j2', sessionId: 's-new', fromState: 'review' }]); }); it('treats a brand-new in-progress job as dispatched from To do', () => { const out = detectDispatches(prev, [ { id: 'j9', state: 'in-progress', agentSessionId: 's9' }, ]); expect(out).toEqual([{ jobId: 'j9', sessionId: 's9', fromState: 'todo' }]); }); }); // The note* functions keep module-level state (sync flag, anim queues), so // each test re-imports office.js fresh; the terminal.js mock above survives // vi.resetModules. async function freshMotion() { vi.resetModules(); const office = await import('../public/modules/office.js'); const state = await import('../public/modules/state.js'); return { office, state }; } describe('motion state', () => { it('starts idle and ignores arrivals during the connect sync', async () => { const { office } = await freshMotion(); expect(office.hasMotion()).toBe(false); office.noteAgentArrival('s1'); // before the first jobs-list expect(office.hasMotion()).toBe(false); }); it('queues a walk-in for an arrival after the first jobs-list', async () => { const { office } = await freshMotion(); office.noteJobsUpdate(); // connect sync complete expect(office.hasMotion()).toBe(false); office.noteAgentArrival('s1'); expect(office.hasMotion()).toBe(true); }); it('treats the first jobs update as a baseline and a later dispatch as motion', async () => { const { office, state } = await freshMotion(); state.jobs.set('j1', { id: 'j1', state: 'todo', agentSessionId: null }); office.noteJobsUpdate(); expect(office.hasMotion()).toBe(false); // pre-existing jobs don't animate state.jobs.set('j1', { id: 'j1', state: 'in-progress', agentSessionId: 's1' }); office.noteJobsUpdate(); expect(office.hasMotion()).toBe(true); // dispatch paper queued }); it('ignores departures for unknown or disconnected agents, or with no panel', async () => { const { office, state } = await freshMotion(); office.noteAgentDeparture('ghost'); // not in the agent map state.agents.set('sd', { state: 'DISCONNECTED' }); office.noteAgentDeparture('sd'); // already gone from the floor state.agents.set('sa', { state: 'WORKING' }); office.noteAgentDeparture('sa'); // no #office-canvas in this DOM expect(office.hasMotion()).toBe(false); }); }); // The panel is a flex column with .office-header above the canvas, so a // panel-sized canvas overflowed the bottom and clipped the sofa and plants. // Both sizing paths must read the canvas's own CSS box, never the panel's. function canvasDom(w = 600, h = 400) { document.body.innerHTML = '
'; const panel = document.getElementById('office-panel'); const canvas = document.getElementById('office-canvas'); const dim = (el, props) => { for (const [k, v] of Object.entries(props)) Object.defineProperty(el, k, { value: v, configurable: true }); }; dim(panel, { offsetWidth: 900, offsetHeight: 700 }); dim(canvas, { clientWidth: w, clientHeight: h }); // happy-dom has no 2D context; every call is a no-op that returns the ctx // (so chained results like createLinearGradient().addColorStop still work). const ctx = new Proxy({}, { get: () => () => ctx, set: () => true }); canvas.getContext = () => ctx; return { panel, canvas }; } describe('canvas sizing', () => { it('renderOffice sizes the backing store from the canvas box, not the panel', async () => { const { office } = await freshMotion(); const { canvas } = canvasDom(); window.devicePixelRatio = 2; office.renderOffice(); expect(canvas.width).toBe(600 * 2); expect(canvas.height).toBe(400 * 2); expect(canvas.style.width).toBe(''); expect(canvas.style.height).toBe(''); }); it('noteAgentDeparture lays out from the canvas box and queues a walk-out', async () => { const { office, state } = await freshMotion(); canvasDom(); state.agents.set('sa', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.noteAgentDeparture('sa'); expect(office.hasMotion()).toBe(true); }); it('keeps the last real size while the canvas is hidden (diff viewer)', async () => { const { office, state } = await freshMotion(); const { canvas } = canvasDom(); window.devicePixelRatio = 1; office.renderOffice(); // learns 600x400 Object.defineProperty(canvas, 'clientWidth', { value: 0, configurable: true }); Object.defineProperty(canvas, 'clientHeight', { value: 0, configurable: true }); office.renderOffice(); expect(canvas.width).toBe(600); // not collapsed to 0x0 state.agents.set('sa', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.noteAgentDeparture('sa'); expect(office.hasMotion()).toBe(true); // walk-out captured from the remembered room }); it('renders nothing before the first layout', async () => { const { office } = await freshMotion(); const { canvas } = canvasDom(); Object.defineProperty(canvas, 'clientWidth', { value: 0, configurable: true }); Object.defineProperty(canvas, 'clientHeight', { value: 0, configurable: true }); office.renderOffice(); expect(canvas.width).toBe(300); // untouched default }); }); // Idle wander: driven entirely through renderOffice on a canvas tall enough // for the conference set to place (one pod row + spares end at y≈507; the // set needs 326px of band above the chat areas, so 1000px works). describe('idle wander', () => { const seatIdle = async () => { const ctx = await freshMotion(); canvasDom(600, 1000); ctx.office.noteJobsUpdate(); // connect sync complete — anims may play ctx.state.agents.set('si', { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); ctx.office.renderOffice(); return ctx; }; it('does not animate pre-existing idle agents during the connect replay', async () => { const { office, state } = await freshMotion(); canvasDom(600, 1000); state.agents.set('si', { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); // before the first jobs-list: seat claimed silently expect(office.hasMotion()).toBe(false); }); it('walks a WAITING agent too, not just an IDLE one', async () => { // Every TUI agent short-circuits to WAITING in detectState, so gating the // wander on IDLE alone meant it never fired for the agents this board // actually spawns. const { office, state } = await freshMotion(); canvasDom(600, 1000); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(true); }); it('walks an idle agent toward a conference seat after the sync', async () => { const { office } = await seatIdle(); expect(office.hasMotion()).toBe(true); // wander anim keeps the loop alive }); it('snaps back without a return walk when a job lands mid-walk', async () => { const { office, state } = await seatIdle(); state.agents.set('si', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(false); // no teleport-to-seat-then-walk }); it('walks a seated agent back to its desk when its state changes', async () => { const { office, state } = await seatIdle(); const t0 = performance.now(); const spy = vi.spyOn(performance, 'now').mockReturnValue(t0 + 60000); office.renderOffice(); // walk completes — agent is now seated expect(office.hasMotion()).toBe(false); state.agents.set('si', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(true); // walk back to the desk spy.mockRestore(); }); it('queues a walk-out for a departing sitter', async () => { const { office, state } = await seatIdle(); const t0 = performance.now(); const spy = vi.spyOn(performance, 'now').mockReturnValue(t0 + 60000); office.renderOffice(); // seated spy.mockRestore(); state.agents.set('si', { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); office.noteAgentDeparture('si'); expect(office.hasMotion()).toBe(true); // walk-out, from the seat }); }); // Walk in/out: drawn through renderOffice on the same 1000px-tall canvas as // the wander, so the route bends around a placed conference set. describe('walk in/out', () => { const later = (ms) => { const t0 = performance.now(); return vi.spyOn(performance, 'now').mockReturnValue(t0 + ms); }; it('walks a new agent in to its desk, and gives up on one that never reaches the floor', async () => { const { office, state } = await freshMotion(); canvasDom(600, 1000); office.noteJobsUpdate(); // connect sync complete office.noteAgentArrival('s1'); office.noteAgentArrival('ghost'); office.renderOffice(); // no desks yet — both walks wait for the agent map expect(office.hasMotion()).toBe(true); state.agents.set('s1', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); // s1's route resolves and the walk starts expect(office.hasMotion()).toBe(true); const spy = later(60000); office.renderOffice(); // s1 arrived; ghost waited past its 4s grace spy.mockRestore(); expect(office.hasMotion()).toBe(false); }); it('walks a departing agent out to the entrance and clears the floor', async () => { const { office, state } = await freshMotion(); canvasDom(600, 1000); office.noteJobsUpdate(); state.agents.set('sa', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); // seated at its desk, conference set placed office.noteAgentDeparture('sa'); state.agents.delete('sa'); // desk gone; the walk-out keeps its captured start office.renderOffice(); expect(office.hasMotion()).toBe(true); const spy = later(60000); office.renderOffice(); spy.mockRestore(); expect(office.hasMotion()).toBe(false); }); }); // A walk's route is frozen when it starts: an unrelated agent spawning // mid-stride changes the obstacles, and recomputing the path every frame // re-projected the walker (distance advances on wall-clock time, so a longer // route moves it a desk over) instead of leaving it where it was walking. describe('frozen walk routes', () => { // drawWalker bails without a loaded sheet, so make sprites load instantly // and read the walker's position back off drawImage. const walkingOffice = async () => { vi.resetModules(); vi.stubGlobal('Image', class { set src(v) { this.src_ = v; this.onload?.(); } }); const office = await import('../public/modules/office.js'); const state = await import('../public/modules/state.js'); const { canvas } = canvasDom(900, 800); const drawn = []; const ctx = new Proxy({}, { get: (_t, k) => k === 'drawImage' ? (img, ...a) => { if (String(img.src_).includes('char')) drawn.push({ x: a[4], y: a[5] }); } : () => ctx, set: () => true, }); canvas.getContext = () => ctx; // The walker is drawn last (the overlay pass runs after the seated one), // so the final char sprite of a frame is the one walking. const frameAt = (ms) => { drawn.length = 0; const spy = vi.spyOn(performance, 'now').mockReturnValue(ms); office.renderOffice(); spy.mockRestore(); return drawn.at(-1); }; return { office, state, frameAt }; }; it('keeps a walker on its route when other agents spawn mid-walk', async () => { const { office, state, frameAt } = await walkingOffice(); office.noteJobsUpdate(); // connect sync complete office.noteAgentArrival('a2'); state.agents.set('a1', { state: 'WORKING', repoPath: '/r1', repoSlug: 'r1' }); state.agents.set('a2', { state: 'WORKING', repoPath: '/r2', repoSlug: 'r2' }); frameAt(0); // the route resolves and the walk starts const before = frameAt(1000); expect(before).toBeDefined(); // Six more agents across both repos: the pods reflow, the obstacle set // grows and the corridor row and approach column both move. for (let i = 3; i <= 8; i++) { state.agents.set('a' + i, { state: 'WORKING', repoPath: i % 2 ? '/r1' : '/r2', repoSlug: i % 2 ? 'r1' : 'r2' }); } expect(frameAt(1000)).toEqual(before); // same elapsed time, same spot expect(office.hasMotion()).toBe(true); // still mid-walk, not clamped at the end }); it('rebuilds the route when the canvas resizes mid-walk', async () => { const { office, state, frameAt } = await walkingOffice(); office.noteJobsUpdate(); office.noteAgentArrival('a2'); state.agents.set('a1', { state: 'WORKING', repoPath: '/r1', repoSlug: 'r1' }); state.agents.set('a2', { state: 'WORKING', repoPath: '/r2', repoSlug: 'r2' }); frameAt(0); const before = frameAt(1000); const canvas = document.getElementById('office-canvas'); Object.defineProperty(canvas, 'clientHeight', { value: 1000, configurable: true }); const after = frameAt(1000); expect(after).toBeDefined(); expect(after).not.toEqual(before); // the room really did change — relay out }); }); describe('sofa seats', () => { // 1440x900 is an ordinary laptop canvas: the band between the desks and the // chat areas is too short for the conference set (it needs ~972px), so the // sofas are the only place an idle agent has to go. const W = 1440, H = 900; it('offers the sofas when the canvas is too short for a conference table', () => { const decor = computeDecorPlacement([], W, H); const chats = decor.filter(d => d.kind === 'chat'); expect(chats.length).toBe(2); const seats = wanderSeats(null, decor); expect(seats.length).toBe(6); // three per chat area expect(seats.every(s => s.kind === 'chat')).toBe(true); }); it('pools the sofas with the conference seats when both are there', () => { const decor = computeDecorPlacement([], W, H); const conf = { seats: [{ x: 1, y: 2, row: 0 }, { x: 3, y: 4, row: 2 }] }; const seats = wanderSeats(conf, decor); expect(seats.map(s => s.kind)).toEqual(['conf', 'conf', ...Array(6).fill('chat')]); }); it('sits each sitter on a sofa, not on the rug or the coffee table', () => { // drawDecor's own layout math, repeated here so a change to it that moves // the sofas out from under the sitters fails rather than silently drifts. const D = 2, SOFA_W = 16 * D, SOFA_H = 32 * D, SOFA_FRONT_H = 16 * D, TABLE_W = 32 * D, TABLE_H = 32 * D; const PAD_X = 13, PAD_TOP = 14, GAP = 8; const spot = computeDecorPlacement([], W, H).find(d => d.kind === 'chat'); const tx = spot.x + PAD_X + SOFA_W + GAP, ty = spot.y + PAD_TOP + SOFA_FRONT_H + GAP; const sy = ty + TABLE_H - SOFA_H - D; const sofas = [ { x: tx, y: spot.y + PAD_TOP, w: TABLE_W, h: SOFA_FRONT_H }, // front, far side { x: spot.x + PAD_X, y: sy, w: SOFA_W, h: SOFA_H }, // left { x: tx + TABLE_W + GAP, y: sy, w: SOFA_W, h: SOFA_H }, // right ]; const seats = chatSeats(spot); seats.forEach((seat, i) => { const body = { x: seat.x, y: seat.y, w: CHAR_W, h: CHAR_H }; expect(overlaps(body, sofas[i]), `sitter ${i} is not on its sofa`).toBe(true); // Centred on it horizontally, so nobody perches on an armrest. expect(seat.x + CHAR_W / 2).toBe(sofas[i].x + sofas[i].w / 2); }); expect(seats[2].mirror).toBe(true); // right sofa faces back in expect(seats[0].y).toBeLessThan(seats[1].y); // front sofa is the far one // ...and sits high enough on it to clear the coffee table below. The // seated frame's art starts 4px lower in its box than the standing one's, // so reusing the side sofas' offset dropped this body onto the tabletop. expect(seats[0].y + CHAR_H, 'the front sitter spills onto the coffee table').toBeLessThanOrEqual(ty); }); // Built the way renderOffice builds them: pod rugs, spare desks, occupied // desks and the conference bbox, not just the chat rugs. The earlier version // of this test passed `decor` alone and omitted panelHeight, so it never // routed past a rug and never exercised corridorY's clamp -- far narrower // than its name. Both canvas sizes matter: at 900 there is no conference set // and the sofas are the only seats, at 1000 a sofa route has to cross the // whole room above a conference set that is in the way. for (const [w, h] of [[600, 900], [600, 1000]]) { it(`routes to a sofa without walking over the furniture at ${w}x${h}`, () => { const { pods, positions } = computePodLayout([{ id: 'a', repoPath: '/r', slug: 'r' }], w, h); const rugs = pods.map(podRugRect); const decor = computeDecorPlacement(rugs, w, h); const spares = computeSpareDesks(pods, decor, w, h); const conf = computeConference(rugs, spares, decor, w, h); const obstacles = walkObstacles(rugs, decor, spares, conf, [...positions.values()]); const chats = decor.filter(d => d.kind === 'chat'); const sofas = wanderSeats(conf, decor).filter(s => s.kind === 'chat'); expect(sofas.length).toBeGreaterThan(0); const desk = positions.get('a'); for (const seat of sofas) { // The seat's own chat area is the destination: the descent lands in it // and, when corridorY can only find a standable row above the // conference set, that descent is a long one that finishes inside the // rug. Every OTHER chat area is furniture and must stay unswept. const own = chats.find(c => seat.x >= c.x && seat.x < c.x + c.w); const others = chats.filter(c => c !== own); expect(own, 'every sofa seat belongs to a chat area').toBeTruthy(); const p = wanderRoute(desk, seat, conf, true, obstacles, w, h); for (const l of p.legs) for (const o of others) expect(overlaps(swept(l), o), `${w}x${h} leg ${l.x0},${l.y0}-${l.x1},${l.y1} crosses the other chat area`).toBe(false); } }); } it('walks a resting agent to a sofa at a canvas with no conference table', async () => { const { office, state } = await freshMotion(); canvasDom(W, H); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(true); }); }); // The sofas are the seats that always exist (the conference set needs ~972px // of canvas height), so these drive the wander on an ordinary 1440x900 laptop // canvas where computeConference returns null. describe('sofa wander', () => { const W = 1440, H = 900; // Like the frozen-route harness above, but the recorded draws are read as a // whole frame rather than just its last walker: sofa sitters go down before // the desk pass. The filter is the characters/ folder, not 'char' — the // conference CHAIR sprites match that too. Mirrored sitters draw through a // canvas transform, so they land at 0,0: count them, never place them. const sofaOffice = async (w = W, h = H) => { vi.resetModules(); vi.stubGlobal('Image', class { set src(v) { this.src_ = v; this.onload?.(); } }); const office = await import('../public/modules/office.js'); const state = await import('../public/modules/state.js'); const { canvas } = canvasDom(w, h); const drawn = []; const ctx = new Proxy({}, { get: (_t, k) => k === 'drawImage' ? (img, ...a) => { if (String(img.src_).includes('characters/')) drawn.push({ x: a[4], y: a[5], col: a[0] / CHAR_FRAME_W }); } : () => ctx, set: () => true, }); canvas.getContext = () => ctx; const at = (ms, fn) => { const spy = vi.spyOn(performance, 'now').mockReturnValue(ms); try { return fn(); } finally { spy.mockRestore(); } }; const frameAt = (ms) => at(ms, () => { drawn.length = 0; office.renderOffice(); return drawn.slice(); }); return { office, state, canvas, at, frameAt }; }; // The seat pool renderOffice will build for a given set of agents, from the // same exported pieces it uses — so a layout change moves the expectations // with it instead of stranding a hardcoded coordinate. // Position of a recorded draw. The recorder also carries the sprite-sheet // column, which animates between frames, so comparing whole records across // frames is brittle — compare this instead. const posOf = d => ({ x: d.x, y: d.y }); const seatsFor = (ids, w = W, h = H) => { const layout = computePodLayout(ids.map(id => ({ id, repoPath: '/r', slug: 'r' })), w, h); const decor = computeDecorPlacement(layout.pods.map(podRugRect), w, h); return { layout, decor, seats: wanderSeats(null, decor) }; }; it('drops in-flight claims when the seat list changes shape', async () => { // Seat indices address the pool, so the conference set vanishing on a // resize (12 seats -> the 6 sofas) must not leave a claim pointing at a // seat that is now someone else's. The walker restarts from its desk. const { office, state, frameAt } = await sofaOffice(600, 1000); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); const desk = frameAt(0).at(-1); // route resolves; walker still at the desk const moved = frameAt(3000); expect(posOf(moved.at(-1))).not.toEqual(posOf(desk)); // genuinely mid-walk to a conference seat const canvas = document.getElementById('office-canvas'); Object.defineProperty(canvas, 'clientHeight', { value: 900, configurable: true }); // Same wall clock as the mid-walk frame: only a cleared claim (and the // fresh anim that replaces it) puts the walker back at its desk. expect(posOf(frameAt(3000).at(-1))).toEqual(posOf(desk)); }); it('waits for an agent to be quiet a while before sending it off', async () => { // WAITING is not "at rest": detectState flips WORKING -> WAITING after 3s // of silence, and a walk to a seat takes 7-11s at WALK_SPEED. Without a // dwell window an agent that paused to think starts a walk, gets output, // and snaps back to its desk -- jittering forever and never arriving. const { office, state, frameAt } = await sofaOffice(); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r', lastOutputAt: Date.now() }); frameAt(0); expect(office.hasMotion(), 'a briefly-quiet agent left its desk').toBe(false); // Quiet past the window the board already calls "stalled": now it goes. state.agents.get('si').lastOutputAt = Date.now() - 4 * 60 * 1000; frameAt(0); expect(office.hasMotion(), 'a long-quiet agent stayed put').toBe(true); }); it('holds an IDLE agent at its desk too until it has been quiet a while', async () => { // A plain shell is not a TUI and its prompt need not match PROMPT_PATTERNS, // so detectState calls it IDLE, not WAITING. Guarding WAITING alone left // every bash terminal pacing: 3s after each command it set off for a sofa, // the next line of output snapped it back, and it never arrived. const { office, state, frameAt } = await sofaOffice(); office.noteJobsUpdate(); state.agents.set('si', { state: 'IDLE', repoPath: '/r', repoSlug: 'r', lastOutputAt: Date.now() }); frameAt(0); expect(office.hasMotion(), 'a just-quiet bash terminal left its desk').toBe(false); state.agents.get('si').lastOutputAt = Date.now() - 4 * 60 * 1000; frameAt(0); expect(office.hasMotion(), 'a long-quiet bash terminal stayed put').toBe(true); }); it('leaves an agent blocked on a question, or gone, at its desk', async () => { // The whole reason lib/helpers.js widens MESSAGE_PATTERNS in this same // change: a question-blocked agent must read MESSAGE, not WAITING, or it // strolls off to a sofa while its desk stands empty. Pin both non-resting // states out of the gate, or that pairing can silently come apart. for (const state_ of ['MESSAGE', 'DISCONNECTED']) { const { seats } = seatsFor(['si']); const { office, state, frameAt } = await sofaOffice(); office.noteJobsUpdate(); state.agents.set('si', { state: state_, repoPath: '/r', repoSlug: 'r' }); const frame = frameAt(0); expect(office.hasMotion(), `${state_} started a wander`).toBe(false); for (const seat of seats) expect(frame.some(d => d.x === seat.x && d.y === seat.y), `${state_} agent drawn on a sofa`).toBe(false); } }); it('seats as many agents as there are sofas and leaves the rest at their desks', async () => { const ids = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7']; const { layout, seats } = seatsFor(ids); expect(seats).toHaveLength(6); // six sofas, seven agents const { office, state, at, frameAt } = await sofaOffice(); for (const id of ids) state.agents.set(id, { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); frameAt(0); // connect replay: claims are silent, everyone is already in place expect(office.hasMotion()).toBe(false); // The seventh never got a seat, so its walk-out starts at its own desk. at(5000, () => office.noteAgentDeparture('a7')); state.agents.delete('a7'); const start = frameAt(5000).at(-1); const pos = layout.positions.get('a7'); expect(start.x).toBeGreaterThanOrEqual(pos.x); expect(start.x).toBeLessThan(pos.x + 32 * 3); expect(start.y).toBeGreaterThanOrEqual(pos.y); expect(start.y).toBeLessThan(pos.y + 36 * 3); expect(seats.some(s => s.x === start.x && s.y === start.y)).toBe(false); }); it('walks a departing sitter out from the sofa it is on', async () => { const ids = ['a1', 'a2']; const { seats } = seatsFor(ids); const { office, state, at, frameAt } = await sofaOffice(); for (const id of ids) state.agents.set(id, { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); frameAt(0); // seated on the first two sofas at(5000, () => office.noteAgentDeparture('a1')); state.agents.delete('a1'); expect(posOf(frameAt(5000).at(-1))).toEqual({ x: seats[0].x, y: seats[0].y }); }); it('draws an arrived sitter on its sofa, and nothing there while it walks over', async () => { const { seats } = seatsFor(['si']); const onSofa = f => f.some(d => d.x === seats[0].x && d.y === seats[0].y); const seated = await sofaOffice(); seated.state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); expect(onSofa(seated.frameAt(0))).toBe(true); // connect replay: straight to the sofa expect(seated.office.hasMotion()).toBe(false); const walking = await sofaOffice(); walking.office.noteJobsUpdate(); walking.state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); const frame = walking.frameAt(0); expect(walking.office.hasMotion()).toBe(true); expect(onSofa(frame)).toBe(false); // still mid-wander — only the walker draws expect(frame).toHaveLength(1); }); it('walks back from a sofa along the same route, reversed', async () => { // The walk back rebuilds from a synthetic seat carrying the sofa's own // lane and crossing row. Without them there is nothing to fall back on: // the conference table it would derive them from is null at this size. const decor = computeDecorPlacement([], W, H); const seats = wanderSeats(null, decor); const desk = { x: 300, y: FLOOR_TOP + 20 }; for (const seat of seats) { const there = wanderRoute(desk, seat, null, true, decor, W, H); const from = { x: seat.x, y: seat.y, row: seat.row, lane: seat.lane, yPref: seat.yPref }; const back = wanderRoute(desk, from, null, false, decor, W, H); expect(back.from).toEqual({ x: seat.x, y: seat.y }); expect(back.legs).toEqual( [...there.legs].reverse().map(l => ({ x0: l.x1, y0: l.y1, x1: l.x0, y1: l.y0 }))); // Load-bearing, not decoration: strip them and the route changes. It must // NOT throw, though — wanderRoute is called from inside the rAF loop, so // a TypeError here takes the whole office render down, not one walker. const stripped = wanderRoute(desk, { x: seat.x, y: seat.y, row: seat.row }, null, false, decor, W, H); expect(stripped.legs).not.toEqual(back.legs); } }); it('draws an arrived sitter in the seated frame, not the standing one', async () => { // The standing frame (col 1) has legs and feet, so a sitter drawn with it // reads as standing ON the sofa. Columns 3-6 are the seated frames — torso // only, flat bottom — which is what the desk pass already uses for a // seated agent. Nothing asserted the frame before, so the wrong one shipped. const { seats } = seatsFor(['si']); const { office, state, frameAt } = await sofaOffice(); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r', lastOutputAt: 0 }); frameAt(0); frameAt(60000); // the frame the walk completes on const seated = frameAt(60000); // drawChatSitters runs before drawMotion, so // the sitter first appears the frame after const onSeat = seated.filter(d => seats.some(s => s.x === d.x && s.y === d.y)); expect(onSeat.length, 'nobody drawn on a sofa').toBeGreaterThan(0); for (const d of onSeat) expect(d.col, `sitter drawn with frame column ${d.col}, wanted the seated one`).toBe(3); }); it('walks a sofa sitter back to its desk when it starts working', async () => { const { office, state } = await freshMotion(); canvasDom(W, H); office.noteJobsUpdate(); state.agents.set('si', { state: 'WAITING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); const t0 = performance.now(); let spy = vi.spyOn(performance, 'now').mockReturnValue(t0 + 60000); office.renderOffice(); // arrived on the sofa spy.mockRestore(); expect(office.hasMotion()).toBe(false); state.agents.set('si', { state: 'WORKING', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(true); // no conference table needed to route home spy = vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 60000); office.renderOffice(); spy.mockRestore(); expect(office.hasMotion()).toBe(false); // and it gets there }); it('leaves everyone at their desks when the room fits no seat at all', async () => { expect(wanderSeats(null, [])).toEqual([]); expect(wanderSeats(null, [{ kind: 'cactus', x: 0, y: 0 }])).toEqual([]); const { office, state } = await freshMotion(); canvasDom(300, 300); // one tall rug, no chat areas, no conference set office.noteJobsUpdate(); state.agents.set('si', { state: 'IDLE', repoPath: '/r', repoSlug: 'r' }); office.renderOffice(); expect(office.hasMotion()).toBe(false); }); it('carries each sofa its own approach, and keeps the conference seats indexed', () => { const spot = computeDecorPlacement([], W, H).find(d => d.kind === 'chat'); for (const s of chatSeats(spot)) { expect(s.kind).toBe('chat'); expect(s.lane).toBe(s.x); // the seat's own column — nothing stands over it expect(s.yPref).toBe(spot.y - 24); // cross just above the rug, not through it } const conf = { seats: [{ x: 1, y: 2, row: 0 }, { x: 3, y: 4, row: 2 }] }; const pooled = wanderSeats(conf, [spot]); expect(pooled.slice(0, 2)).toEqual([ { x: 1, y: 2, row: 0, kind: 'conf', confIndex: 0 }, { x: 3, y: 4, row: 2, kind: 'conf', confIndex: 1 }, ]); // Conference seats stay first, so drawConference's index-keyed draw passes // still address conf.seats[i] with the same i. expect(pooled.slice(2).every(s => s.kind === 'chat')).toBe(true); }); // The conference set needs ~972px of height, so these use a taller canvas // than the sofa tests above — the sofaOffice harness is the same one. it('draws the side sitters in the seated frame and the end sitters standing', async () => { // Columns 3-6 are the seated frames: torso only, no legs, built for a desk // to occlude the lower body. A side sitter needs one — its legs would hang // beside the chair otherwise. The head and foot sitters must NOT get one: // the tabletop covers the head's legs and the chair-back covers the foot's, // and a legless torso there floats. const CW = 1440, CH = 1100; const ids = ['s0', 's1', 's2', 's3', 's4', 's5']; const infos = ids.map(id => ({ id, repoPath: '/r', slug: 'r' })); const layout = computePodLayout(infos, CW, CH); const rugs = layout.pods.map(podRugRect); const decor = computeDecorPlacement(rugs, CW, CH); const conf = computeConference(rugs, computeSpareDesks(layout.pods, decor, CW, CH), decor, CW, CH); expect(conf, 'no conference set on this canvas').not.toBeNull(); const { office, state, frameAt } = await sofaOffice(CW, CH); office.noteJobsUpdate(); // Seats fill in index order, so agent i takes conference seat i. for (const id of ids) state.agents.set(id, { state: 'WAITING', repoPath: '/r', repoSlug: 'r', lastOutputAt: 0 }); frameAt(0); frameAt(60000); const seated = frameAt(60000); // Head (0) and foot (5) face the viewer/away and keep the standing frame; // the unmirrored side seats (1, 3) take the seated one. for (const [i, col] of [[0, 1], [1, 3], [3, 3], [5, 1]]) { const drawn = seated.filter(d => d.x === conf.seats[i].x && d.y === conf.seats[i].y); expect(drawn.length, `nobody drawn on conference seat ${i}`).toBe(1); expect(drawn[0].col, `seat ${i} drawn with frame column ${drawn[0].col}`).toBe(col); } // Seats 2 and 4 are mirrored, so they draw through a canvas transform at // 0,0 — their column is still recorded, and it is the seated one. const mirrored = seated.filter(d => d.x === 0 && d.y === 0); expect(mirrored.length, 'the mirrored side sitters are missing').toBe(2); for (const d of mirrored) expect(d.col).toBe(3); }); });