test/client-office-pods.test.js
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'node:fs';
// 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 { computePodLayout, podRugRect, computeDecorPlacement, SPRITE_PATHS, computeBookshelfRuns, computeBoardLayout, computeSpareDesks, computeConference, entryRoute, wanderRoute, walkObstacles, corridorY } =
await import('../public/modules/office.js');
const Z = 3;
const WS_W = 32, WS_H = 36, WS_GAP_X = 12, WS_GAP_Y = 18;
const FLOOR_TOP = (36 * Z + 2 * Z) + 26 * Z; // WALL_BOTTOM + 26 Z
const TOP_MARGIN = (8 + 7) * Z; // POD_TOP_MARGIN + RUG_PAD_TOP: desk y when the floor has room
const W = 900, H = 700;
function agentsOn(...repoLists) {
// agentsOn(['a','r1'], ['b','r1'], ['c', null]) → infos in spawn order
return repoLists.map(([id, repo]) => ({ id, repoPath: repo, slug: repo ? repo.split('/').pop() : null }));
}
// The leg that crosses the room: the horizontal one running to the entrance.
// A blocked descent adds a second horizontal — the sidestep along the desk's
// own row — so "the first horizontal leg" is not the crossing any more.
const crossingLeg = p => p.legs.find(l => l.y0 === l.y1 && (l.x0 === Z || l.x1 === Z));
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;
// A route leg's swept area: the walker box dragged from one end to the other.
const sweptLeg = l => ({
x: Math.min(l.x0, l.x1), y: Math.min(l.y0, l.y1),
w: Math.abs(l.x1 - l.x0) + 16 * 2, h: Math.abs(l.y1 - l.y0) + 32 * 2,
});
describe('pod layout', () => {
it('groups agents by repo into separate pods, no-repo agents in a final unlabeled pod', () => {
const { pods, positions } = computePodLayout(agentsOn(
['a', '/r/one'], ['b', '/r/two'], ['c', '/r/one'], ['d', null],
), W, H);
expect(pods.map(p => p.repoPath)).toEqual(['/r/one', '/r/two', null]);
expect(pods.map(p => p.label)).toEqual(['one', 'two', null]);
expect(positions.size).toBe(4);
// a and c share the pod, side by side in one front-facing row
expect(positions.get('c').y).toBe(positions.get('a').y);
expect(positions.get('c').x - positions.get('a').x).toBe((WS_W + WS_GAP_X) * Z);
});
it('seats Billion alone at the top center, above every pod, whenever it arrived', () => {
const infos = [
...agentsOn(['a', '/r/one'], ['c', null]),
{ id: 'billion', repoPath: null, slug: null, isBillion: true },
...agentsOn(['b', '/r/one']),
];
const { pods, positions } = computePodLayout(infos, W, H);
// Its own unlabeled pod, first, and not merged with the no-repo pod.
expect(pods.map(p => p.repoPath)).toEqual([null, '/r/one', null]);
expect(pods.map(p => p.label)).toEqual([null, 'one', null]);
const billion = positions.get('billion');
expect(billion.x).toBe(Math.floor((W - WS_W * Z) / 2));
for (const id of ['a', 'b', 'c']) expect(positions.get(id).y).toBeGreaterThan(billion.y);
});
it('anchors a sparse office near the top of the floor, but still centers a full one', () => {
const one = computePodLayout(agentsOn(['a', '/r/one']), W, H);
expect(one.positions.get('a').y).toBe(FLOOR_TOP + TOP_MARGIN);
expect(podRugRect(one.pods[0]).y).toBe(FLOOR_TOP + 8 * Z);
// Floor exactly one desk-block tall plus a hair: centering offset < margin wins.
const shortH = FLOOR_TOP + WS_H * Z + 2 * Z;
expect(computePodLayout(agentsOn(['a', '/r/one']), W, shortH).positions.get('a').y).toBe(FLOOR_TOP + Z);
// Overflowing floor: never above FLOOR_TOP.
expect(computePodLayout(agentsOn(['a', '/r/one']), W, FLOOR_TOP).positions.get('a').y).toBe(FLOOR_TOP);
});
it('a single repo lays out as a top-anchored front-facing grid, wrapping after maxCols', () => {
const infos = agentsOn(['a', '/r/one'], ['b', '/r/one'], ['c', '/r/one'], ['d', '/r/one'], ['e', '/r/one']);
const { pods, positions } = computePodLayout(infos, W, H);
expect(pods).toHaveLength(1);
// 5 agents → 4 columns (maxCols) × 2 rows, anchored near the top of the floor
const cols = 4, rows = 2;
const gridW = (cols * WS_W + (cols - 1) * WS_GAP_X) * Z;
const gridH = (rows * WS_H + (rows - 1) * WS_GAP_Y) * Z;
const startX = Math.floor((W - gridW) / 2);
const startY = Math.max(FLOOR_TOP, FLOOR_TOP + Math.min(TOP_MARGIN, Math.floor((H - FLOOR_TOP - gridH) / 2)));
infos.forEach(({ id }, i) => {
expect(positions.get(id)).toEqual({
x: startX + (i % cols) * (WS_W + WS_GAP_X) * Z,
y: startY + Math.floor(i / cols) * (WS_H + WS_GAP_Y) * Z,
});
});
});
it('fills exactly maxCols in one row, and sizes a wrapped pod to its full grid', () => {
const four = agentsOn(['a', '/r/one'], ['b', '/r/one'], ['c', '/r/one'], ['d', '/r/one']);
const full = computePodLayout(four, W, H);
// 4 agents on a 4-col panel: one row, no empty second row reserved
expect(full.pods[0].w).toBe((4 * WS_W + 3 * WS_GAP_X) * Z);
expect(full.pods[0].h).toBe(WS_H * Z);
expect(new Set(four.map(({ id }) => full.positions.get(id).y)).size).toBe(1);
// A fifth agent wraps: the rug grows by exactly one row plus the aisle
const { pods } = computePodLayout([...four, ...agentsOn(['e', '/r/one'])], W, H);
expect(pods[0].w).toBe((4 * WS_W + 3 * WS_GAP_X) * Z);
expect(pods[0].h).toBe((2 * WS_H + WS_GAP_Y) * Z);
});
it('a lone agent gets a single desk', () => {
const { pods } = computePodLayout(agentsOn(['a', '/r/one']), W, H);
expect(pods[0].w).toBe(WS_W * Z);
expect(pods[0].h).toBe(WS_H * Z); // one desk: no aisle, no second row
});
it('wraps pods to a new row when they do not fit, and rugs never overlap', () => {
const infos = agentsOn(
['a', '/r/1'], ['b', '/r/1'], ['c', '/r/2'], ['d', '/r/2'],
['e', '/r/3'], ['f', '/r/3'], ['g', '/r/4'], ['h', '/r/4'],
);
const { pods } = computePodLayout(infos, 400, 900);
expect(pods.length).toBe(4);
expect(new Set(pods.map(p => p.y)).size).toBeGreaterThan(1); // wrapped
const rugs = pods.map(podRugRect);
for (let i = 0; i < rugs.length; i++)
for (let j = i + 1; j < rugs.length; j++)
expect(overlaps(rugs[i], rugs[j]), `rug ${i} vs ${j}`).toBe(false);
// Desks stay on the open floor
for (const p of pods) expect(p.y).toBeGreaterThanOrEqual(FLOOR_TOP);
});
it('keeps within-pod desk order stable when an unrelated agent spawns or exits', () => {
const base = agentsOn(['a', '/r/one'], ['b', '/r/one'], ['c', '/r/one']);
const rel = (positions, pod) => base.map(({ id }) => {
const p = positions.get(id);
return { dx: p.x - pod.x, dy: p.y - pod.y };
});
const before = computePodLayout(base, W, H);
const withStranger = computePodLayout(
[...base, ...agentsOn(['z', '/r/other'])], W, H);
const podBefore = before.pods[0];
const podAfter = withStranger.pods.find(p => p.repoPath === '/r/one');
expect(rel(withStranger.positions, podAfter)).toEqual(rel(before.positions, podBefore));
});
it('an empty office yields no pods and no positions, but decor still places', () => {
const { pods, positions } = computePodLayout([], W, H);
expect(pods).toEqual([]);
expect(positions.size).toBe(0);
expect(computeDecorPlacement(pods.map(podRugRect), W, H).length).toBeGreaterThan(0);
});
it('clamps to a single column on a narrow panel and keeps desks inside it', () => {
const narrow = (WS_W + 4) * Z; // room for one desk, not two
const infos = agentsOn(['a', '/r/one'], ['b', '/r/one'], ['c', '/r/one']);
const { pods, positions } = computePodLayout(infos, narrow, 2000);
expect(pods[0].w).toBe(WS_W * Z); // one column
const xs = new Set(infos.map(({ id }) => positions.get(id).x));
expect(xs.size).toBe(1); // all stacked vertically
// One desk per row, a full aisle between rows
expect(positions.get('b').y - positions.get('a').y).toBe((WS_H + WS_GAP_Y) * Z);
expect(positions.get('c').y - positions.get('a').y).toBe(2 * (WS_H + WS_GAP_Y) * Z);
// The rug ends at the last desk row
const lastDeskBottom = Math.max(...infos.map(({ id }) => positions.get(id).y)) + WS_H * Z;
expect(pods[0].y + pods[0].h).toBe(lastDeskBottom);
for (const { id } of infos) {
const p = positions.get(id);
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.x + WS_W * Z).toBeLessThanOrEqual(narrow);
}
});
it('falls back to the repo basename as the pod label when slug is missing', () => {
const { pods } = computePodLayout([{ id: 'a', repoPath: '/r/deep/cobra', slug: null }], W, H);
expect(pods[0].label).toBe('cobra');
// Trailing slash must not yield an empty (hidden) label
const trailing = computePodLayout([{ id: 'a', repoPath: '/r/deep/cobra/', slug: null }], W, H);
expect(trailing.pods[0].label).toBe('cobra');
});
it('decor fills empty floor, yields to pods, and never overlaps a rug', () => {
// Empty office: decor everywhere it fits
expect(computeDecorPlacement([], W, H).length).toBeGreaterThan(0);
// Crowded office: a rug covering the whole floor leaves no decor
const floorRug = { x: 0, y: FLOOR_TOP, w: W, h: H - FLOOR_TOP };
expect(computeDecorPlacement([floorRug], W, H)).toEqual([]);
// Normal office: whatever is placed clears every rug and stays on the floor
const { pods } = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/two']), W, H);
const rugs = pods.map(podRugRect);
const spots = computeDecorPlacement(rugs, W, H);
const MARGIN = 6 * Z; // decor keeps this clearance so it also clears the glow halos
const inflate = (r) => ({ x: r.x - MARGIN, y: r.y - MARGIN, w: r.w + 2 * MARGIN, h: r.h + 2 * MARGIN });
for (const s of spots) {
expect(s.y).toBeGreaterThanOrEqual(FLOOR_TOP);
expect(s.x + s.w).toBeLessThanOrEqual(W);
expect(s.y + s.h).toBeLessThanOrEqual(H);
for (const r of rugs) expect(overlaps(s, inflate(r))).toBe(false);
}
// Same inputs → same placement (deterministic)
expect(computeDecorPlacement(rugs, W, H)).toEqual(spots);
});
it('an empty office gets a chat area in each bottom corner with the plants stacked between them', () => {
const spots = computeDecorPlacement([], W, H);
expect(spots.map(s => s.kind).sort()).toEqual(['cactus', 'chat', 'chat', 'plant2']);
const [left, right] = spots.filter(s => s.kind === 'chat');
for (const chat of [left, right]) {
expect(chat.w).toBe(170); // two side sofas, the table, gaps and padding
expect(chat.h).toBe(130); // front sofa above the table
expect(chat.y + chat.h).toBe(H - 3 * Z);
}
expect(left.x).toBe(13 * Z);
expect(right.x + right.w).toBe(W - 13 * Z);
// The divider: leafy plant stacked above the cactus, centred, drawn small
const plant = spots.find(s => s.kind === 'plant2'), cactus = spots.find(s => s.kind === 'cactus');
for (const p of [plant, cactus]) {
expect(p.w).toBe(20); expect(p.h).toBe(40); // 16×32 art at the divider scale
expect(Math.abs((p.x + p.w / 2) - W / 2)).toBeLessThanOrEqual(1);
}
expect(cactus.y + cactus.h).toBe(H - 3 * Z);
expect(plant.y + plant.h).toBeLessThan(cactus.y);
});
it('a chat area yields to a pod row that reaches down into it', () => {
const [left] = computeDecorPlacement([], W, H).filter(s => s.kind === 'chat');
const rug = { x: left.x, y: left.y - 6 * Z, w: left.w, h: 10 }; // just inside the margin
const survivors = computeDecorPlacement([rug], W, H).filter(s => s.kind === 'chat');
expect(survivors.length).toBe(1);
expect(survivors[0].x).toBe(W - 170 - 13 * Z); // the RIGHT one survives, not the blocked left
const clear = { ...rug, y: left.y - 6 * Z - 10 };
expect(computeDecorPlacement([clear], W, H).filter(s => s.kind === 'chat').length).toBe(2);
});
it('every sprite path points at a file that exists (a missing one silently drops its decor group)', () => {
for (const [key, path] of Object.entries(SPRITE_PATHS)) expect(existsSync('public/' + path), key).toBe(true);
});
it('a bookshelf run sits centred under each window, feet tight against the wall', () => {
const WINDOW_W = 28 * Z;
for (const w of [280, 440, 600, W]) {
const board = computeBoardLayout(w);
const runs = computeBookshelfRuns(w);
expect(runs.length, `panel ${w}`).toBe(2);
// Windows sit between the board sections: section i's right edge + 1Z gap
runs.forEach((r, i) => {
const winX = board.sections[i].right + Z;
expect(r.w).toBe(3 * 12 * 2); // three 12px-wide units at 2x
expect(r.x, `panel ${w}`).toBeGreaterThanOrEqual(winX);
expect(r.x + r.w, `panel ${w}`).toBeLessThanOrEqual(winX + WINDOW_W);
expect(Math.abs((r.x - winX) - (winX + WINDOW_W - r.x - r.w)), `panel ${w} centred`).toBeLessThanOrEqual(1);
expect(r.y + r.h).toBe(board.wallBottom + Z);
expect(r.y + r.h).toBeLessThan(board.footY);
});
}
// Too narrow for a board: no shelves either
expect(computeBookshelfRuns(120)).toEqual([]);
});
it('spare desks fill the row below a single pod row and vanish as the office fills', () => {
const one = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/two']), W, H);
const decor = computeDecorPlacement(one.pods.map(podRugRect), W, H);
const spare = computeSpareDesks(one.pods, decor, W, H);
expect(spare.length).toBe(3);
expect(spare.map(d => d.variant)).toEqual([0, 1, 0]);
// One row-pitch below the real row, on the desk grid, centred
for (const d of spare) expect(d.y).toBe(one.pods[0].y + (WS_H + WS_GAP_Y) * Z);
expect(spare[1].x - spare[0].x).toBe((WS_W + WS_GAP_X) * Z);
expect(Math.abs((spare[0].x + spare[2].x + WS_W * Z) / 2 - W / 2)).toBeLessThanOrEqual(1);
// Clear of the chat area and the panel bottom
const chat = decor.find(s => s.kind === 'chat');
expect(spare[0].y + WS_H * Z + 6 * Z).toBeLessThanOrEqual(chat.y);
// One pod: the spare desks share the real desks' columns
const solo = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/one']), W, H);
const soloSpare = computeSpareDesks(solo.pods, [], W, H);
const colOf = (x) => ((x - solo.pods[0].x) % ((WS_W + WS_GAP_X) * Z) + (WS_W + WS_GAP_X) * Z) % ((WS_W + WS_GAP_X) * Z);
for (const d of soloSpare) expect(colOf(d.x)).toBe(0);
// Two rows of real pods: none
const two = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/one'], ['c', '/r/one'], ['d', '/r/one'], ['e', '/r/one']), W, H);
expect(two.pods[0].h).toBeGreaterThan(WS_H * Z);
expect(computeSpareDesks(two.pods, computeDecorPlacement(two.pods.map(podRugRect), W, H), W, H)).toEqual([]);
// Two pod rows (wrapped): none
const wrapped = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/two']), 2 * (WS_W + 4) * Z, H);
expect(new Set(wrapped.pods.map(p => p.y)).size).toBe(2);
expect(computeSpareDesks(wrapped.pods, [], 2 * (WS_W + 4) * Z, H)).toEqual([]);
// Short panel: the spare row would intrude on the chat area
const short = computePodLayout(agentsOn(['a', '/r/one']), W, 600);
const shortDecor = computeDecorPlacement(short.pods.map(podRugRect), W, 600);
expect(shortDecor.map(s => s.kind)).toContain('chat');
expect(computeSpareDesks(short.pods, shortDecor, W, 600)).toEqual([]);
// Empty office: no real row to sit below
expect(computeSpareDesks([], computeDecorPlacement([], W, H), W, H)).toEqual([]);
// Narrow panel: only as many spare desks as columns fit (300px → 2)
const narrow = computePodLayout(agentsOn(['a', '/r/one']), 300, H);
expect(computeSpareDesks(narrow.pods, [], 300, H).length).toBe(2);
// No decor at all, but the row would run into the panel bottom: none
const shallow = computePodLayout(agentsOn(['a', '/r/one']), W, 500);
expect(computeSpareDesks(shallow.pods, [], W, 500)).toEqual([]);
});
it('the conference table centres in the band between the desks and the chat areas', () => {
const SET_TOP = 18, SET_BOTTOM = 32, SET_H = 240 + SET_TOP + SET_BOTTOM;
const decor = computeDecorPlacement([], W, H);
const conf = computeConference([], [], decor, W, H);
expect(conf).not.toBeNull();
expect(Math.abs((conf.table.x + conf.table.w / 2) - W / 2)).toBeLessThanOrEqual(1);
expect(conf.seats.length).toBe(6);
// Equal gaps above the head seat and below the foot chair
const chatTop = decor.find(s => s.kind === 'chat').y;
const above = (conf.table.y - SET_TOP) - (FLOOR_TOP);
const below = chatTop - (conf.table.y + 240 + SET_BOTTOM);
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
// Deterministic
expect(computeConference([], [], decor, W, H)).toEqual(conf);
// Yields when the spare-desk row leaves too little room
const { pods } = computePodLayout(agentsOn(['a', '/r/one'], ['b', '/r/two']), W, H);
const rugs = pods.map(podRugRect);
const spots = computeDecorPlacement(rugs, W, H);
const spares = computeSpareDesks(pods, spots, W, H);
expect(spares.length).toBe(3);
expect(computeConference(rugs, spares, spots, W, H)).toBeNull();
// On a taller panel it returns, sitting clear below the spare row
const TALL = 1100;
const tallSpots = computeDecorPlacement(rugs, W, TALL);
const tallSpares = computeSpareDesks(pods, tallSpots, W, TALL);
const tall = computeConference(rugs, tallSpares, tallSpots, W, TALL);
expect(tall).not.toBeNull();
const spareBottom = tallSpares[0].y + WS_H * Z;
expect(tall.table.y - SET_TOP).toBeGreaterThanOrEqual(spareBottom + 6 * Z);
expect(tall.table.y + 240 + SET_BOTTOM + 6 * Z)
.toBeLessThanOrEqual(tallSpots.find(s => s.kind === 'chat').y);
// Chairs and seats stay inside the panel
for (const r of [...tall.chairs, ...tall.seats]) {
expect(r.x).toBeGreaterThanOrEqual(0);
expect(r.x + 16 * Z).toBeLessThanOrEqual(W);
}
});
it('the conference set seats both ends, each chair tucked against the tabletop', () => {
const conf = computeConference([], [], [], W, H);
const CHAIR = 16 * Z;
// The table sprite's top 11 of 64 rows are transparent, so the painted
// tabletop starts here — not at conf.table.y. An end chair placed against
// the sprite box instead floats in the open floor above the table.
const topEdge = conf.table.y + Math.round(11 / 64 * conf.table.h);
const head = conf.chairs.find(c => c.kind === 'confchairfront');
const foot = conf.chairs.find(c => c.kind === 'confchairback');
expect(head).toBeDefined();
expect(foot).toBeDefined();
// Both end chairs share the table's centreline
expect(head.x).toBe(foot.x);
// Head chair faces the viewer and stands flush on the tabletop's near edge.
// It draws behind the table, so any overlap is swallowed and it reads as a
// stub — this is the mirror of the foot chair's tuck, not a copy of it.
expect(head.y + CHAIR).toBe(topEdge);
// ...and it stays below the head sitter, who bounds the set from above
expect(head.y).toBeGreaterThan(conf.seats[0].y);
// Foot chair faces away and tucks 16px under the far edge, the mirror of it
expect(conf.table.y + conf.table.h - foot.y).toBe(16);
// Neither end chair escapes the set bbox the walk route steers around
const [set] = walkObstacles([], [], [], conf).slice(-1);
expect(head.y).toBeGreaterThanOrEqual(set.y);
expect(foot.y + CHAIR).toBeLessThanOrEqual(set.y + set.h);
});
it('sits the side sitters on their chairs, not hanging off the front of them', () => {
// A side sitter draws the seated frame, which has no legs, so its box has
// to end on the chair rather than below it — the standing-frame offset put
// the body 4px past the chair's bottom edge and it read as sliding off.
// Seat order is head, then alternating sides top-down: 1,3 are the left
// chairs (0,1), 2,4 the right ones (2,3).
const conf = computeConference([], [], [], W, H);
const CHAIR = 16 * Z, CHAR_H = 64;
const chairFor = [0, 2, 1, 3];
for (let i = 1; i <= 4; i++) {
const chair = conf.chairs[chairFor[i - 1]];
const bottom = conf.seats[i].y + CHAR_H;
expect(bottom, `seat ${i} hangs below its chair`).toBeLessThanOrEqual(chair.y + CHAIR);
expect(bottom, `seat ${i} floats above its chair`).toBeGreaterThan(chair.y);
}
});
it('the conference table yields on a panel narrower than its chair overhang', () => {
expect(computeConference([], [], [], 200, 700)).toBeNull();
});
it('with no chat areas the conference band runs to the panel bottom', () => {
const SET_TOP = 18, SET_BOTTOM = 32;
const conf = computeConference([], [], [], W, H);
expect(conf).not.toBeNull();
// Centred between FLOOR_TOP and the bottom margin (the empty-spread
// Math.min must fall back to panelHeight - 3 Z, not Infinity/NaN)
const above = (conf.table.y - SET_TOP) - FLOOR_TOP;
const below = (H - 3 * Z) - (conf.table.y + 240 + SET_BOTTOM);
expect(above).toBeGreaterThan(0);
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
});
it('the walk-in entrance stands on bare floor, clear of the decor and the conference chairs', () => {
for (const w of [440, 458, W]) {
const decor = computeDecorPlacement([], w, H);
const conf = computeConference([], [], decor, w, H);
const obstacles = walkObstacles([], decor, [], conf);
// Wherever the route puts the entrance, the walker standing there is clear
const e = entryRoute({ x: 200, y: FLOOR_TOP }, conf, true, H, obstacles).from;
const walker = { x: e.x, y: e.y, w: 16 * 2, h: 32 * 2 };
for (const s of decor) expect(overlaps(walker, s), `panel ${w}: ${s.kind}`).toBe(false);
for (const c of conf.chairs) expect(overlaps(walker, { ...c, w: 16 * Z, h: 16 * Z }), `panel ${w}: chair`).toBe(false);
}
});
it('the walk in/out route around a conference set corridors along a clear row', () => {
// 1400px tall: the set places with room to spare, so the bands above and
// below it are wide enough to walk
const T = 1400;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
expect(conf).not.toBeNull();
const obstacles = walkObstacles(rugs, decor, spares, conf);
const desk = positions.get('a');
const entry = { x: Z, y: corridorY(obstacles, T, desk.y) };
for (const inbound of [true, false]) {
const p = entryRoute(desk, conf, inbound, T, obstacles);
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);
for (const l of p.legs) expect(l.x0 === l.x1 || l.y0 === l.y1, 'axis-aligned leg').toBe(true);
// The crossing leg is the clear row nearest the desk: it touches nothing
const across = crossingLeg(p);
expect(across.y0).toBe(corridorY(obstacles, T, desk.y));
for (const o of obstacles) expect(overlaps(sweptLeg(across), o), `${inbound ? 'in' : 'out'}: corridor`).toBe(false);
// The entrance is that row's left end, so nothing walks the left strip
expect(p.legs.some(l => l.x0 === l.x1 && l.x0 === entry.x),
`${inbound ? 'in' : 'out'}: leg on the left strip`).toBe(false);
}
});
it('a walk-out from the conference foot seat steps out via the lane, never up through the table', () => {
const T = 1400;
const { pods } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const obstacles = walkObstacles(rugs, decor, spares, conf);
const foot = conf.seats[5];
const p = entryRoute(foot, conf, false, T, obstacles);
// The sitter steps sideways out of the table's columns first
expect(p.legs[0].y0).toBe(p.legs[0].y1);
for (const l of p.legs) {
if (l.x0 !== l.x1) continue;
const clearOfTable = l.x0 + 16 * 2 <= conf.table.x || l.x0 >= conf.table.x + conf.table.w;
expect(clearOfTable, `vertical leg at x=${l.x0} runs through the table`).toBe(true);
}
});
it('a walk-out from the conference set steps around what blocks the lane', () => {
const T = 1400;
const { pods } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const obstacles = walkObstacles(rugs, decor, spares, conf);
const foot = conf.seats[5];
// The lane the clear floor picks, then something parked in its column
// between the set and the corridor — a plant, a spare desk, anything the
// table's geometry cannot see.
const clear = entryRoute(foot, conf, false, T, obstacles);
const descent = clear.legs.find(l => l.x0 === l.x1);
const blocker = { x: descent.x0 - 20, y: descent.y1 + 31, w: 80, h: 60 };
const p = entryRoute(foot, conf, false, T, [...obstacles, blocker], W);
for (const l of p.legs)
expect(overlaps(sweptLeg(l), blocker), `leg ${l.x0},${l.y0}-${l.x1},${l.y1} crosses the blocker`).toBe(false);
// Still out sideways first, and still never down through the table
expect(p.legs[0].y0).toBe(p.legs[0].y1);
for (const l of p.legs)
if (l.x0 === l.x1)
expect(l.x0 + 16 * 2 <= conf.table.x || l.x0 >= conf.table.x + conf.table.w,
`vertical leg at x=${l.x0} runs through the table`).toBe(true);
});
it('the wander to a conference seat steps around what blocks the lane', () => {
const T = 1400;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const obstacles = walkObstacles(rugs, decor, spares, conf);
const desk = positions.get('a');
const seat = conf.seats[5]; // the foot seat: approached down an outer lane
// The lane the clear floor picks, then something parked in its column
// between the set and the crossing row — a plant, a spare desk, anything
// the table's geometry cannot see.
const clear = wanderRoute(desk, seat, conf, true, obstacles, W, T);
const descent = clear.legs.find(l => l.x0 === l.x1 && l.y1 === seat.y);
const blocker = { x: descent.x0 - 20, y: descent.y0 + 31, w: 80, h: 60 };
for (const toSeat of [true, false]) {
const p = wanderRoute(desk, seat, conf, toSeat, [...obstacles, blocker], W, T);
for (const l of p.legs)
expect(overlaps(sweptLeg(l), blocker),
`${toSeat ? 'to' : 'from'} leg ${l.x0},${l.y0}-${l.x1},${l.y1} crosses the blocker`).toBe(false);
// Still out sideways from between the chairs, and still never down
// through the tabletop
const step = toSeat ? p.legs[p.legs.length - 1] : p.legs[0];
expect(step.y0).toBe(step.y1);
// (the horizontal at the seat row goes through the chairs by design —
// that is the step out from between them)
for (const l of p.legs)
if (l.x0 === l.x1)
expect(overlaps(sweptLeg(l), conf.table),
`vertical leg at x=${l.x0} runs through the table`).toBe(false);
}
});
it('the wander to the head seat still comes straight down its own column', () => {
// The head chair sits above the table, so its own column is the clear one
// — vetting the lane must not push that approach out to a side lane.
const T = 1400;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const obstacles = walkObstacles(rugs, decor, spares, conf);
const head = conf.seats[0];
const p = wanderRoute(positions.get('a'), head, conf, true, obstacles, W, T);
const last = p.legs[p.legs.length - 1];
expect({ x: last.x1, y: last.y1 }).toEqual({ x: head.x, y: head.y });
expect(last.x0).toBe(head.x); // straight down, no sidestep at the seat row
});
it('the wander to the head seat clears the furniture in a room too tight to spare a column', () => {
// 990px: the set only just places, and a spare desk sits square in the head
// seat's own column. Taking that column on faith — it is above the table,
// so it was assumed clear — walked the agent straight over the desk.
const T = 990;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const desks = [...positions.values()];
const obstacles = walkObstacles(rugs, decor, spares, conf, desks);
// The set is the walker's destination and its own floor once inside, so it
// is the one rect the route is allowed to enter.
const setBox = walkObstacles([], [], [], conf, [])[0];
const others = obstacles.filter(o => o.x !== setBox.x || o.y !== setBox.y || o.w !== setBox.w || o.h !== setBox.h);
const desk = positions.get('a');
const mine = others.filter(o => overlaps({ x: desk.x, y: desk.y, w: 16 * 2, h: 32 * 2 }, o));
const p = wanderRoute(desk, conf.seats[0], conf, true, obstacles, W, T);
for (const l of p.legs)
for (const o of others)
if (!mine.includes(o))
expect(overlaps(sweptLeg(l), o),
`leg ${l.x0},${l.y0}-${l.x1},${l.y1} crosses ${JSON.stringify(o)}`).toBe(false);
});
it('on a panel too tight for a clear row the conference route still clears the chairs', () => {
// 1000px tall: the set places with only its 6 Z margins, so no band fits a
// whole character and the route crosses on the widest gap it can find
const T = 1000;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
const obstacles = walkObstacles(rugs, decor, spares, conf);
const chairs = conf.chairs.map(c => ({ ...c, w: 16 * Z, h: 16 * Z }));
for (const inbound of [true, false]) {
const p = entryRoute(positions.get('a'), conf, inbound, T, obstacles);
for (const l of p.legs)
for (const c of chairs) expect(overlaps(sweptLeg(l), c), `${inbound ? 'in' : 'out'}: chair at ${c.x},${c.y}`).toBe(false);
const across = crossingLeg(p);
expect(across.y0).toBeLessThan(conf.table.y);
}
});
it('a packed panel still keeps the crossing off the chat furniture', () => {
// 900x700 with one agent: rug, spare row and chats leave gaps of 24, 18, 54
// and 9px against a 64px character, so nothing fits. The old fallback put
// the crossing on the bottom edge — straight through both sofa areas.
const T = 700;
const { pods, positions } = computePodLayout(agentsOn(['a', '/r/one']), W, T);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, W, T);
const spares = computeSpareDesks(pods, decor, W, T);
const conf = computeConference(rugs, spares, decor, W, T);
expect(conf).toBeNull();
const obstacles = walkObstacles(rugs, decor, spares, conf);
const chats = decor.filter(s => s.kind === 'chat');
expect(chats.length).toBe(2);
for (const inbound of [true, false]) {
const p = entryRoute(positions.get('a'), conf, inbound, T, obstacles);
const across = crossingLeg(p);
expect(across.y0).not.toBe(T - 32 * 2);
for (const c of chats)
expect(overlaps(sweptLeg(across), c), `${inbound ? 'in' : 'out'}: chat at ${c.x},${c.y}`).toBe(false);
}
});
it('spare desks and the conference set keep out of the entry strip on narrow panels', () => {
const STRIP = Z + 16 * 2;
// Spare rows that would centre into the strip drop a desk instead of sitting on the entrance
for (const [w, ids] of [[420, ['a', 'b', 'c']], [260, ['a']]]) {
const { pods } = computePodLayout(agentsOn(...ids.map(id => [id, '/r/one'])), w, H);
const spares = computeSpareDesks(pods, computeDecorPlacement(pods.map(podRugRect), w, H), w, H);
expect(spares.length, `panel ${w}`).toBeGreaterThan(0);
for (const d of spares) expect(d.x, `panel ${w}`).toBeGreaterThanOrEqual(STRIP);
}
// The set yields while its left chairs would overhang the strip, and clears it once placed
expect(computeConference([], [], [], 297, 1100)).toBeNull();
for (const w of [298, 320, 440]) {
const conf = computeConference([], [], [], w, 1100);
expect(conf, `panel ${w}`).not.toBeNull();
for (const c of conf.chairs) expect(c.x, `panel ${w}`).toBeGreaterThanOrEqual(STRIP);
}
});
it('with no conference set the walk in/out corridors along a clear row, not the chat furniture', () => {
// 8 agents on a 700px panel: two pod rows, no room for the set
const ids = Array.from({ length: 8 }, (_, i) => [`s${i}`, '/r/one']);
const { pods, positions } = computePodLayout(agentsOn(...ids), 700, 800);
const rugs = pods.map(podRugRect);
const decor = computeDecorPlacement(rugs, 700, 800);
const spares = computeSpareDesks(pods, decor, 700, 800);
expect(computeConference(rugs, spares, decor, 700, 800)).toBeNull();
// The desks go in the list the way renderOffice passes them, so the
// no-desk-is-touched assertion below covers the sidestep leg too.
const obstacles = walkObstacles(rugs, decor, spares, null, [...positions.values()]);
const desk = positions.get('s7');
for (const inbound of [true, false]) {
const p = entryRoute(desk, null, inbound, 800, obstacles, 700);
const across = crossingLeg(p);
// The old route crossed along the bottom edge — straight through the chat rugs
expect(across.y0).not.toBe(800 - 32 * 2);
expect(across.y0).toBe(corridorY(obstacles, 800, desk.y));
for (const o of obstacles) expect(overlaps(sweptLeg(across), o), `${inbound ? 'in' : 'out'}: corridor`).toBe(false);
// Only the destination's own desk is touched: every other desk is clear of every leg
for (const [id, d] of positions) {
if (id === 's7') continue;
const other = { x: d.x, y: d.y, w: WS_W * Z, h: WS_H * Z };
for (const l of p.legs) {
expect(overlaps(sweptLeg(l), other), `${inbound ? 'in' : 'out'}: desk ${id}`).toBe(false);
}
}
}
});
it('decor spots never overlap each other, even on a narrow panel', () => {
for (const w of [140, 180, 240, W]) {
const spots = computeDecorPlacement([], w, H);
for (let i = 0; i < spots.length; i++)
for (let j = i + 1; j < spots.length; j++)
expect(overlaps(spots[i], spots[j]), `panel ${w}: spot ${i} vs ${j}`).toBe(false);
}
});
});