agent-ecosystem/test/renderer/features/agent-graph/useGraphSimulation.test.ts

836 lines
29 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
buildStableSlotLayoutSnapshot,
computeOwnerFootprints,
computeProcessBandWidth,
resolveNearestSlotAssignment,
snapshotToWorldBounds,
translateSlotFrame,
validateStableSlotLayout,
} from '../../../../packages/agent-graph/src/layout/stableSlots';
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants';
import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
import {
STABLE_SLOT_GEOMETRY,
STABLE_SLOT_SECTOR_VECTORS,
} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph';
function createLead(teamName: string): GraphNode {
return {
id: `lead:${teamName}`,
kind: 'lead',
label: `${teamName}-lead`,
state: 'active',
domainRef: { kind: 'lead', teamName, memberName: 'lead' },
};
}
function createMember(teamName: string, stableOwnerId: string, memberName: string): GraphNode {
return {
id: `member:${teamName}:${stableOwnerId}`,
kind: 'member',
label: memberName,
state: 'active',
domainRef: { kind: 'member', teamName, memberName },
};
}
function createTask(
teamName: string,
taskId: string,
ownerId?: string | null,
overrides?: Partial<GraphNode>
): GraphNode {
return {
id: `task:${taskId}`,
kind: 'task',
label: `#${taskId}`,
displayId: `#${taskId}`,
state: 'idle',
ownerId: ownerId ?? null,
taskStatus: 'pending',
domainRef: { kind: 'task', teamName, taskId },
...overrides,
};
}
function createProcess(teamName: string, processId: string, ownerId: string): GraphNode {
return {
id: `process:${teamName}:${processId}`,
kind: 'process',
label: processId,
state: 'active',
ownerId,
domainRef: { kind: 'process', teamName, processId },
};
}
function rectsOverlap(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number }
): boolean {
return (
left.left < right.right &&
left.right > right.left &&
left.top < right.bottom &&
left.bottom > right.top
);
}
describe('stable slot layout planner', () => {
it('does not build a stable slot snapshot when the lead is missing', () => {
const snapshot = buildStableSlotLayoutSnapshot({
teamName: 'team-no-lead',
nodes: [createMember('team-no-lead', 'agent-alice', 'alice')],
layout: {
version: 'stable-slots-v1',
ownerOrder: ['member:team-no-lead:agent-alice'],
slotAssignments: {
'member:team-no-lead:agent-alice': { ringIndex: 0, sectorIndex: 1 },
},
},
});
expect(snapshot).toBeNull();
});
it('builds lead activity inside the same central owner slot topology', () => {
const teamName = 'team-a';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
expect(snapshot).not.toBeNull();
expect(snapshot?.leadNodeId).toBe(lead.id);
expect(snapshot?.launchAnchor).toBeNull();
expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id);
expect(snapshot?.memberSlotFrames).toHaveLength(1);
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0);
expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left);
expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top);
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
});
it('builds a board band that contains both the activity column and kanban band', () => {
const teamName = 'team-process-width';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
const frame = snapshot?.memberSlotFrames[0];
expect(frame).toBeDefined();
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0));
});
it('uses strict cardinal owner slots for teams with up to four members', () => {
const teamName = 'team-cardinal-four';
const lead = createLead(teamName);
const top = createMember(teamName, 'agent-top', 'top');
const right = createMember(teamName, 'agent-right', 'right');
const bottom = createMember(teamName, 'agent-bottom', 'bottom');
const left = createMember(teamName, 'agent-left', 'left');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [top.id, right.id, bottom.id, left.id],
slotAssignments: {
[top.id]: { ringIndex: 0, sectorIndex: 0 },
[right.id]: { ringIndex: 0, sectorIndex: 1 },
[bottom.id]: { ringIndex: 0, sectorIndex: 2 },
[left.id]: { ringIndex: 0, sectorIndex: 3 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, top, right, bottom, left],
layout,
});
expect(snapshot).not.toBeNull();
const topFrame = snapshot!.memberSlotFrameByOwnerId.get(top.id)!;
const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!;
const bottomFrame = snapshot!.memberSlotFrameByOwnerId.get(bottom.id)!;
const leftFrame = snapshot!.memberSlotFrameByOwnerId.get(left.id)!;
expect(Math.abs(topFrame.ownerX)).toBeLessThan(1);
expect(topFrame.ownerY).toBeLessThan(0);
expect(rightFrame.ownerX).toBeGreaterThan(0);
expect(Math.abs(rightFrame.ownerY)).toBeLessThan(1);
expect(Math.abs(bottomFrame.ownerX)).toBeLessThan(1);
expect(bottomFrame.ownerY).toBeGreaterThan(0);
expect(leftFrame.ownerX).toBeLessThan(0);
expect(Math.abs(leftFrame.ownerY)).toBeLessThan(1);
expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1);
expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1);
});
it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => {
const teamName = 'team-cardinal-misaligned-order';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const tom = createMember(teamName, 'agent-tom', 'tom');
const jack = createMember(teamName, 'agent-jack', 'jack');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [jack.id, alice.id, tom.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 0 },
[bob.id]: { ringIndex: 0, sectorIndex: 1 },
[tom.id]: { ringIndex: 0, sectorIndex: 2 },
[jack.id]: { ringIndex: 0, sectorIndex: 3 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob, tom, jack],
layout,
});
expect(snapshot).not.toBeNull();
const aliceFrame = snapshot!.memberSlotFrameByOwnerId.get(alice.id)!;
const bobFrame = snapshot!.memberSlotFrameByOwnerId.get(bob.id)!;
const tomFrame = snapshot!.memberSlotFrameByOwnerId.get(tom.id)!;
const jackFrame = snapshot!.memberSlotFrameByOwnerId.get(jack.id)!;
expect(Math.abs(aliceFrame.ownerX)).toBeLessThan(1);
expect(aliceFrame.ownerY).toBeLessThan(0);
expect(bobFrame.ownerX).toBeGreaterThan(0);
expect(Math.abs(bobFrame.ownerY)).toBeLessThan(1);
expect(Math.abs(tomFrame.ownerX)).toBeLessThan(1);
expect(tomFrame.ownerY).toBeGreaterThan(0);
expect(jackFrame.ownerX).toBeLessThan(0);
expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1);
});
it('reserves a full empty activity column and minimum kanban width for idle members', () => {
const teamName = 'team-empty-slot';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const [footprint] = computeOwnerFootprints([lead, alice], layout);
expect(footprint).toBeDefined();
expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width);
expect(footprint?.activityColumnHeight).toBe(
ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight
);
expect(footprint?.kanbanBandWidth).toBe(TASK_PILL.width);
expect(footprint?.boardBandHeight).toBe(
Math.max(footprint?.activityColumnHeight ?? 0, footprint?.kanbanBandHeight ?? 0)
);
});
it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => {
const teamName = 'team-directional-radius';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
const [footprint] = computeOwnerFootprints([lead, alice], layout);
const frame = snapshot?.memberSlotFrames[0];
const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1];
expect(snapshot).not.toBeNull();
expect(frame).toBeDefined();
expect(footprint).toBeDefined();
const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right;
const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top);
const legacyRequiredX =
(legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.x);
const legacyRequiredY =
(legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.y);
const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0);
const actualRadius = Math.abs(frame!.ownerX / sectorVector.x);
expect(actualRadius).toBeLessThan(legacyMinRadius);
expect(
snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))
).toBe(false);
});
it('grows process band width when an owner has multiple visible process nodes', () => {
const teamName = 'team-process-growth';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const processes = Array.from({ length: 7 }, (_, index) =>
createProcess(teamName, `proc-${index + 1}`, alice.id)
);
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const [footprint] = computeOwnerFootprints([lead, alice, ...processes], layout);
expect(footprint).toBeDefined();
expect(footprint?.processCount).toBe(7);
expect(footprint?.processBandWidth).toBe(computeProcessBandWidth(7));
expect((footprint?.processBandWidth ?? 0) > STABLE_SLOT_GEOMETRY.processRailWidth).toBe(true);
});
it('includes full topology bounds for fit, not only activity overlays', () => {
const teamName = 'team-fit';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
const bounds = snapshotToWorldBounds(snapshot!);
expect(bounds[0]).toEqual({
left: snapshot!.fitBounds.left,
top: snapshot!.fitBounds.top,
right: snapshot!.fitBounds.right,
bottom: snapshot!.fitBounds.bottom,
});
});
it('rejects invalid overlapping slot frames in validation pass', () => {
const teamName = 'team-invalid';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const [firstFrame] = snapshot!.memberSlotFrames;
const invalid = {
...snapshot!,
memberSlotFrames: snapshot!.memberSlotFrames.map((frame, index) =>
index === 1
? {
...frame,
bounds: firstFrame.bounds,
}
: frame
),
};
expect(validateStableSlotLayout(invalid).valid).toBe(false);
});
it('rejects member frames that overlap the lead central reserved block', () => {
const teamName = 'team-central-rects';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
expect(snapshot).not.toBeNull();
const [frame] = snapshot!.memberSlotFrames;
const overlappingLeadBlock = translateSlotFrame(
frame,
snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1,
snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1
);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingLeadBlock],
}).valid
).toBe(false);
});
it('prefers the occupied target slot when dragging near another owner anchor', () => {
const teamName = 'team-b';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const bobFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === bob.id);
expect(bobFrame).toBeDefined();
const nearest = resolveNearestSlotAssignment({
ownerId: alice.id,
ownerX: bobFrame?.ownerX ?? 0,
ownerY: bobFrame?.ownerY ?? 0,
nodes: [lead, alice, bob],
snapshot: snapshot!,
layout,
});
expect(nearest).not.toBeNull();
expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 2 });
expect(nearest?.displacedOwnerId).toBe(bob.id);
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
});
it('keeps drag resolution inside strict cardinal slots for four-member teams', () => {
const teamName = 'team-cardinal-drag';
const lead = createLead(teamName);
const top = createMember(teamName, 'agent-top', 'top');
const right = createMember(teamName, 'agent-right', 'right');
const bottom = createMember(teamName, 'agent-bottom', 'bottom');
const left = createMember(teamName, 'agent-left', 'left');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [top.id, right.id, bottom.id, left.id],
slotAssignments: {
[top.id]: { ringIndex: 0, sectorIndex: 0 },
[right.id]: { ringIndex: 0, sectorIndex: 1 },
[bottom.id]: { ringIndex: 0, sectorIndex: 2 },
[left.id]: { ringIndex: 0, sectorIndex: 3 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, top, right, bottom, left],
layout,
});
expect(snapshot).not.toBeNull();
const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!;
const nearest = resolveNearestSlotAssignment({
ownerId: top.id,
ownerX: rightFrame.ownerX,
ownerY: rightFrame.ownerY,
nodes: [lead, top, right, bottom, left],
snapshot: snapshot!,
layout,
});
expect(nearest).not.toBeNull();
expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
expect(nearest?.displacedOwnerId).toBe(right.id);
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 0 });
});
it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => {
const teamName = 'team-drag-central-collision';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const nearest = resolveNearestSlotAssignment({
ownerId: alice.id,
ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2,
ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2,
nodes: [lead, alice, bob],
snapshot: snapshot!,
layout,
});
expect(nearest).not.toBeNull();
const replannedSnapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout: {
...layout,
slotAssignments: {
...layout.slotAssignments,
[alice.id]: nearest!.assignment,
...(nearest?.displacedOwnerId && nearest.displacedAssignment
? { [nearest.displacedOwnerId]: nearest.displacedAssignment }
: {}),
},
},
});
const replannedFrame = replannedSnapshot?.memberSlotFrames.find(
(frame) => frame.ownerId === alice.id
);
expect(replannedSnapshot).not.toBeNull();
expect(replannedFrame).toBeDefined();
expect(
replannedSnapshot!.centralCollisionRects.some((rect) =>
rectsOverlap(replannedFrame!.bounds, rect)
)
).toBe(false);
});
it('treats tasks with missing owner nodes as unassigned topology actors', () => {
const teamName = 'team-orphan-task';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const orphanTask = createTask(teamName, 'task-orphan', 'member:team-orphan-task:agent-missing');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, orphanTask],
layout,
});
expect(snapshot).not.toBeNull();
expect(snapshot?.unassignedTaskRect).not.toBeNull();
});
it('rejects member frames that overlap the unassigned central collision rect', () => {
const teamName = 'team-unassigned-central-rect';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const orphanTask = createTask(
teamName,
'task-orphan',
'member:team-unassigned-central-rect:ghost'
);
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, orphanTask],
layout,
});
expect(snapshot?.unassignedTaskRect).not.toBeNull();
const [frame] = snapshot!.memberSlotFrames;
const overlappingUnassigned = translateSlotFrame(
frame,
snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1,
snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1
);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingUnassigned],
}).valid
).toBe(false);
});
it('computes the next ring radius from previous ring depth, not member count', () => {
const teamName = 'team-ring-depth';
const lead = createLead(teamName);
const first = createMember(teamName, 'agent-first', 'member-1');
const second = createMember(teamName, 'agent-second', 'member-2');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [first.id, second.id],
slotAssignments: {
[first.id]: { ringIndex: 0, sectorIndex: 1 },
[second.id]: { ringIndex: 1, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, first, second],
layout,
});
const footprints = computeOwnerFootprints([lead, first, second], layout);
const firstRingFrame = snapshot?.memberSlotFrames.find(
(frame) => frame.ownerId === first.id
);
const secondRingFrame = snapshot?.memberSlotFrames.find(
(frame) => frame.ownerId === second.id
);
expect(snapshot).not.toBeNull();
expect(firstRingFrame).toBeDefined();
expect(secondRingFrame).toBeDefined();
const firstFootprint = footprints.find((footprint) => footprint.ownerId === first.id);
expect(firstFootprint).toBeDefined();
if (!firstFootprint) {
throw new Error('expected first footprint for ring-depth test');
}
const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY)
- Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
const sectorVector = { x: 0.82, y: -0.57 };
const ownerLocalY =
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
const topOffset = -ownerLocalY;
const bottomOffset = firstFootprint.slotHeight - ownerLocalY;
const halfWidth = firstFootprint.slotWidth / 2;
const vectorLength = Math.hypot(sectorVector.x, sectorVector.y) || 1;
const unitX = sectorVector.x / vectorLength;
const unitY = sectorVector.y / vectorLength;
const cornerProjections = [
{ x: -halfWidth, y: topOffset },
{ x: halfWidth, y: topOffset },
{ x: halfWidth, y: bottomOffset },
{ x: -halfWidth, y: bottomOffset },
].map((corner) => corner.x * unitX + corner.y * unitY);
const outwardDepth = Math.max(...cornerProjections);
const inwardDepth = Math.max(...cornerProjections.map((projection) => -projection));
const expectedRingDelta = outwardDepth + inwardDepth + STABLE_SLOT_GEOMETRY.ringGap;
expect(Math.abs(ringDelta - expectedRingDelta)).toBeLessThan(2);
});
it('keeps owned tasks out of unassigned topology when default sector candidates near the lead are invalid', () => {
const teamName = 'team-owned-tasks';
const lead = createLead(teamName);
const members = [
createMember(teamName, 'agent-alice', 'alice'),
createMember(teamName, 'agent-bob', 'bob'),
createMember(teamName, 'agent-tom', 'tom'),
createMember(teamName, 'agent-jack', 'jack'),
];
const tasks = [
createTask(teamName, 'task-a', members[0].id, { taskStatus: 'completed' }),
createTask(teamName, 'task-b', members[1].id, { taskStatus: 'completed' }),
createTask(teamName, 'task-c', members[2].id, { taskStatus: 'completed' }),
createTask(teamName, 'task-d', members[3].id, { taskStatus: 'completed' }),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: members.map((member) => member.id),
slotAssignments: {},
};
const nodes = [lead, ...members, ...tasks];
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes,
layout,
});
expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
expect(snapshot?.unassignedTaskRect).toBeNull();
const memberSlotFrames = snapshot!.memberSlotFrames;
for (const frame of memberSlotFrames) {
const ownerNode = nodes.find((node) => node.id === frame.ownerId);
if (!ownerNode) {
continue;
}
ownerNode.x = frame.ownerX;
ownerNode.y = frame.ownerY;
}
KanbanLayoutEngine.layout(nodes, {
memberSlotFrames,
unassignedTaskRect: snapshot!.unassignedTaskRect,
});
for (const task of tasks) {
const ownerFrame = memberSlotFrames.find((frame) => frame.ownerId === task.ownerId);
expect(ownerFrame).toBeDefined();
expect(task.x).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.left);
expect(task.x).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.right);
expect(task.y).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.top);
expect(task.y).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.bottom);
}
});
it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => {
const teamName = 'team-lead-owned-tasks';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const leadTasks = [
createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }),
createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const nodes = [lead, alice, ...leadTasks];
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes,
layout,
});
expect(snapshot).not.toBeNull();
expect(snapshot?.unassignedTaskRect).toBeNull();
lead.x = snapshot!.leadSlotFrame.ownerX;
lead.y = snapshot!.leadSlotFrame.ownerY;
alice.x = snapshot!.memberSlotFrames[0]?.ownerX;
alice.y = snapshot!.memberSlotFrames[0]?.ownerY;
KanbanLayoutEngine.layout(nodes, {
leadSlotFrame: snapshot!.leadSlotFrame,
memberSlotFrames: snapshot!.memberSlotFrames,
unassignedTaskRect: snapshot!.unassignedTaskRect,
});
for (const task of leadTasks) {
expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left);
expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right);
expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top);
expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom);
}
});
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
const teamName = 'team-wide-spill';
const lead = createLead(teamName);
const narrow = createMember(teamName, 'agent-narrow', 'narrow');
const wide = createMember(teamName, 'agent-wide', 'wide');
const wideTasks = [
createTask(teamName, 'todo', wide.id, { taskStatus: 'pending' }),
createTask(teamName, 'wip', wide.id, { taskStatus: 'in_progress' }),
createTask(teamName, 'done', wide.id, { taskStatus: 'completed' }),
createTask(teamName, 'review', wide.id, { reviewState: 'review' }),
createTask(teamName, 'approved', wide.id, { reviewState: 'approved' }),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [narrow.id, wide.id],
slotAssignments: {
[narrow.id]: { ringIndex: 0, sectorIndex: 1 },
[wide.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, narrow, wide, ...wideTasks],
layout,
});
const wideFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === wide.id);
const warnMock = vi.mocked(console.warn);
expect(snapshot).not.toBeNull();
expect(wideFrame).toBeDefined();
expect(wideFrame?.ringIndex).toBe(1);
expect(wideFrame?.sectorIndex).toBe(1);
expect(warnMock.mock.calls).toHaveLength(1);
warnMock.mockClear();
});
});