- Added stable slot layout support in various components, enhancing the layout and interaction of nodes. - Updated TypeScript configuration to include new paths for the agent-graph package. - Refactored layout logic in activity lanes and kanban to accommodate stable slot assignments. - Enhanced GraphView and GraphControls to support sidebar visibility toggling and owner slot drop handling. - Introduced new types for layout management in GraphDataPort and related files. - Updated README to include stable slot layout documentation.
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
buildStableSlotLayoutSnapshot,
|
|
computeOwnerFootprints,
|
|
resolveNearestSlotAssignment,
|
|
snapshotToWorldBounds,
|
|
validateStableSlotLayout,
|
|
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
|
import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
|
|
import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane';
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 launch and activity geometry around the central lead block', () => {
|
|
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).not.toBeNull();
|
|
expect(snapshot?.memberSlotFrames).toHaveLength(1);
|
|
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
|
|
expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0);
|
|
expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0);
|
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
|
});
|
|
|
|
it('keeps a fixed process rail width centered inside the owner slot', () => {
|
|
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?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth);
|
|
expect(frame?.processBandRect.left).toBeCloseTo(
|
|
(frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2,
|
|
6
|
|
);
|
|
});
|
|
|
|
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('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('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('computes the next ring radius from previous ring depth, not member count', () => {
|
|
const teamName = 'team-ring-depth';
|
|
const lead = createLead(teamName);
|
|
const members = Array.from({ length: 7 }, (_, index) =>
|
|
createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`)
|
|
);
|
|
const layout: GraphLayoutPort = {
|
|
version: 'stable-slots-v1',
|
|
ownerOrder: members.map((member) => member.id),
|
|
slotAssignments: Object.fromEntries(
|
|
members.map((member, index) => [
|
|
member.id,
|
|
{
|
|
ringIndex: index < 6 ? 0 : 1,
|
|
sectorIndex: index % 6,
|
|
},
|
|
])
|
|
),
|
|
};
|
|
|
|
const snapshot = buildStableSlotLayoutSnapshot({
|
|
teamName,
|
|
nodes: [lead, ...members],
|
|
layout,
|
|
});
|
|
const footprints = computeOwnerFootprints([lead, ...members], layout);
|
|
const firstRingFrame = snapshot?.memberSlotFrames.find(
|
|
(frame) => frame.ringIndex === 0 && frame.sectorIndex === 0
|
|
);
|
|
const secondRingFrame = snapshot?.memberSlotFrames.find(
|
|
(frame) => frame.ringIndex === 1 && frame.sectorIndex === 0
|
|
);
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
expect(firstRingFrame).toBeDefined();
|
|
expect(secondRingFrame).toBeDefined();
|
|
const firstFootprint = footprints[0];
|
|
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 ownerAnchorOffsetY =
|
|
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding +
|
|
ACTIVITY_ANCHOR_LAYOUT.reservedHeight +
|
|
STABLE_SLOT_GEOMETRY.slotVerticalGap +
|
|
STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
|
|
const expectedRingDelta =
|
|
ownerAnchorOffsetY +
|
|
(firstFootprint.slotHeight - ownerAnchorOffsetY) +
|
|
STABLE_SLOT_GEOMETRY.ringGap;
|
|
|
|
expect(ringDelta).toBeCloseTo(expectedRingDelta, 6);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|