agent-ecosystem/test/agent-graph/stableSlots.test.ts
2026-05-08 21:48:27 +03:00

271 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildStableSlotLayoutSnapshot,
resolveNearestSlotAssignment,
type StableRect,
type StableSlotLayoutSnapshot,
validateStableSlotLayout,
} from '../../packages/agent-graph/src/layout/stableSlots';
import type {
GraphLayoutPort,
GraphNode,
GraphOwnerSlotAssignment,
} from '../../packages/agent-graph/src/ports/types';
function ownerNode(id: string, kind: 'lead' | 'member' = 'member'): GraphNode {
return {
id,
kind,
label: id,
state: 'idle',
domainRef: {
kind,
teamName: 'test-team',
memberName: id,
},
};
}
function taskNode(id: string, ownerId: string, index: number): GraphNode {
return {
id,
kind: 'task',
label: id,
state: 'idle',
ownerId,
taskStatus: index === 0 ? 'in_progress' : 'pending',
reviewState: index === 1 ? 'review' : 'none',
domainRef: {
kind: 'task',
teamName: 'test-team',
taskId: id,
},
};
}
function buildOwnerGraph(
ownerCount: number,
slotAssignments: Record<string, GraphOwnerSlotAssignment>
): { nodes: GraphNode[]; layout: GraphLayoutPort } {
const nodes: GraphNode[] = [ownerNode('lead', 'lead')];
const ownerOrder = Array.from({ length: ownerCount }, (_, index) => `member-${index}`);
ownerOrder.forEach((ownerId, ownerIndex) => {
nodes.push(ownerNode(ownerId));
for (let taskIndex = 0; taskIndex < 3; taskIndex += 1) {
nodes.push(taskNode(`task-${ownerIndex}-${taskIndex}`, ownerId, taskIndex));
}
});
return {
nodes,
layout: {
version: 'stable-slots-v1',
mode: 'radial',
ownerOrder,
slotAssignments,
},
};
}
function buildSixOwnerGraph(): { nodes: GraphNode[]; layout: GraphLayoutPort } {
return buildOwnerGraph(
6,
Object.fromEntries(
Array.from({ length: 6 }, (_, index) => [
`member-${index}`,
{ ringIndex: 0, sectorIndex: index },
])
)
);
}
function buildRowOrbitGraph(
ownerCount: number,
rowCounts: readonly number[]
): {
nodes: GraphNode[];
layout: GraphLayoutPort;
} {
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
let ownerIndex = 0;
rowCounts.forEach((columnCount, ringIndex) => {
for (let sectorIndex = 0; sectorIndex < columnCount; sectorIndex += 1) {
assignments[`member-${ownerIndex}`] = { ringIndex, sectorIndex };
ownerIndex += 1;
}
});
return buildOwnerGraph(ownerCount, assignments);
}
function getSnapshot(nodes: GraphNode[], layout: GraphLayoutPort): StableSlotLayoutSnapshot {
const snapshot = buildStableSlotLayoutSnapshot({
teamName: 'test-team',
nodes,
layout,
});
expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
return snapshot!;
}
function rectsOverlap(left: StableRect, right: StableRect): boolean {
return (
left.left < right.right &&
left.right > right.left &&
left.top < right.bottom &&
left.bottom > right.top
);
}
function getRowCounts(snapshot: StableSlotLayoutSnapshot): number[] {
const rowCounts = new Map<number, number>();
for (const frame of snapshot.memberSlotFrames) {
rowCounts.set(frame.ringIndex, (rowCounts.get(frame.ringIndex) ?? 0) + 1);
}
return Array.from(rowCounts.entries())
.sort(([left], [right]) => left - right)
.map(([, count]) => count);
}
function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] {
const rows = new Map<number, StableRect[]>();
for (const frame of snapshot.memberSlotFrames) {
rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame.bounds]);
}
return Array.from(rows.entries())
.sort(([left], [right]) => left - right)
.map(([, rects]) => {
const left = Math.min(...rects.map((rect) => rect.left));
const right = Math.max(...rects.map((rect) => rect.right));
return right - left;
});
}
describe('stable slot layout', () => {
it('packs six legacy radial owners into two row-orbit rows', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 3]);
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 2, 2, 2]);
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
});
it('lets six radial owners move into an empty lead-level side slot', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, layout);
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
const targetOwnerX =
snapshot.runtimeCentralExclusion.left - 160 - currentFrame.bounds.width / 2;
const result = resolveNearestSlotAssignment({
ownerId: 'member-0',
ownerX: targetOwnerX,
ownerY: 0,
nodes,
snapshot,
layout,
});
expect(result).toMatchObject({
assignment: { ringIndex: 1, sectorIndex: 0 },
previewOwnerX: targetOwnerX,
previewOwnerY: 0,
});
expect(result?.displacedOwnerId).toBeUndefined();
const nextSnapshot = getSnapshot(nodes, {
...layout,
slotAssignments: {
...layout.slotAssignments,
'member-0': result!.assignment,
},
});
expect(nextSnapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(nextSnapshot.memberSlotFrameByOwnerId.get('member-0')).toMatchObject({
ringIndex: 1,
sectorIndex: 0,
});
});
it('uses three grid columns for six owners in rows layout', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, {
...layout,
mode: 'grid-under-lead',
slotAssignments: {},
});
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
});
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 2, 3]);
const leadRowFrames = snapshot.memberSlotFrames.filter((frame) => frame.ringIndex === 1);
expect(leadRowFrames).toHaveLength(2);
for (const frame of leadRowFrames) {
expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false);
if (frame.ownerX < 0) {
expect(frame.bounds.right).toBeLessThanOrEqual(snapshot.runtimeCentralExclusion.left - 160);
} else {
expect(frame.bounds.left).toBeGreaterThanOrEqual(
snapshot.runtimeCentralExclusion.right + 160
);
}
}
});
it('packs twelve radial owners into four safe rows with no four-column row width', () => {
const { nodes, layout } = buildRowOrbitGraph(12, [3, 3, 3, 3]);
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 3, 3, 3]);
const maxFrameWidth = Math.max(...snapshot.memberSlotFrames.map((frame) => frame.bounds.width));
const maxRowWidth = Math.max(...getRowWidths(snapshot));
expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4);
});
it('swaps with the nearest existing row-orbit slot while dragging', () => {
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
const snapshot = getSnapshot(nodes, layout);
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
const targetFrame = snapshot.memberSlotFrameByOwnerId.get('member-4')!;
const result = resolveNearestSlotAssignment({
ownerId: 'member-0',
ownerX: targetFrame.ownerX,
ownerY: targetFrame.ownerY,
nodes,
snapshot,
layout,
});
expect(result).toEqual({
assignment: {
ringIndex: targetFrame.ringIndex,
sectorIndex: targetFrame.sectorIndex,
},
displacedOwnerId: 'member-4',
displacedAssignment: {
ringIndex: currentFrame.ringIndex,
sectorIndex: currentFrame.sectorIndex,
},
previewOwnerX: targetFrame.ownerX,
previewOwnerY: targetFrame.ownerY,
});
});
});