test/client-office-motion.test.js
Rama main · réplica de lectura del repositorio original.
// @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 =
'<div id="office-panel"><div class="office-header"></div><canvas id="office-canvas"></canvas></div>';
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);
});
});