148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
ACTIVITY_LANE,
|
|
findActivityItemAt,
|
|
getActivityAnchorScreenPlacement,
|
|
getActivityAnchorTarget,
|
|
getActivityLaneBounds,
|
|
packActivityLaneScreenRects,
|
|
packActivityLaneWorldRects,
|
|
getVisibleActivityWindow,
|
|
} from '../../../../packages/agent-graph/src/layout/activityLane';
|
|
|
|
import type { GraphActivityItem, GraphNode } from '@claude-teams/agent-graph';
|
|
|
|
function createItems(count: number): GraphActivityItem[] {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
id: `item-${index + 1}`,
|
|
kind: 'inbox_message',
|
|
timestamp: `2026-04-13T12:00:0${index}Z`,
|
|
title: `Item ${index + 1}`,
|
|
}));
|
|
}
|
|
|
|
describe('activity lane helpers', () => {
|
|
it('keeps the newest visible window in newest-first order', () => {
|
|
const window = getVisibleActivityWindow(createItems(6));
|
|
|
|
expect(window.items.map((item) => item.id)).toEqual(['item-1', 'item-2', 'item-3']);
|
|
expect(window.overflowCount).toBe(3);
|
|
});
|
|
|
|
it('places activity lanes above their owners', () => {
|
|
const leadTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' });
|
|
const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' });
|
|
const memberLeftOfLeadTarget = getActivityAnchorTarget({
|
|
nodeX: 80,
|
|
nodeY: 80,
|
|
nodeKind: 'member',
|
|
leadX: 100,
|
|
});
|
|
|
|
expect(leadTarget.x).toBe(100 - ACTIVITY_LANE.width / 2);
|
|
expect(memberTarget.x).toBe(100 - ACTIVITY_LANE.width / 2);
|
|
expect(memberLeftOfLeadTarget.x).toBe(80 - ACTIVITY_LANE.width / 2);
|
|
expect(leadTarget.y).toBeLessThan(80);
|
|
expect(memberTarget.y).toBeLessThan(80);
|
|
});
|
|
|
|
it('keeps the activity lane fully above the owner node', () => {
|
|
const ownerY = 120;
|
|
const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: ownerY, nodeKind: 'member' });
|
|
const bounds = getActivityLaneBounds(memberTarget.x, memberTarget.y);
|
|
|
|
expect(bounds.bottom).toBeLessThan(ownerY);
|
|
});
|
|
|
|
it('hits visible activity pills in the owner lane', () => {
|
|
const node: GraphNode = {
|
|
id: 'member:team:alice',
|
|
kind: 'member',
|
|
label: 'alice',
|
|
state: 'active',
|
|
x: 100,
|
|
y: 80,
|
|
activityItems: createItems(3),
|
|
domainRef: { kind: 'member', teamName: 'team', memberName: 'alice' },
|
|
};
|
|
|
|
const anchor = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' });
|
|
const bounds = getActivityLaneBounds(anchor.x, anchor.y);
|
|
const hit = findActivityItemAt(
|
|
bounds.left + ACTIVITY_LANE.width / 2,
|
|
bounds.top + ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.itemHeight / 2,
|
|
[node]
|
|
);
|
|
|
|
expect(hit?.ownerNodeId).toBe(node.id);
|
|
expect(hit?.item.id).toBe('item-1');
|
|
});
|
|
|
|
it('keeps activity lane at its world-space position instead of clamping to the viewport', () => {
|
|
const placement = getActivityAnchorScreenPlacement({
|
|
anchorX: 40,
|
|
anchorY: 60,
|
|
cameraX: 0,
|
|
cameraY: 0,
|
|
zoom: 1,
|
|
viewportWidth: 800,
|
|
viewportHeight: 600,
|
|
});
|
|
|
|
expect(placement.x).toBe(40);
|
|
expect(placement.y).toBe(60);
|
|
expect(placement.visible).toBe(true);
|
|
});
|
|
|
|
it('stays visible when only part of the lane is inside the viewport', () => {
|
|
const placement = getActivityAnchorScreenPlacement({
|
|
anchorX: -40,
|
|
anchorY: 40,
|
|
cameraX: 0,
|
|
cameraY: 0,
|
|
zoom: 1,
|
|
viewportWidth: 800,
|
|
viewportHeight: 600,
|
|
});
|
|
|
|
expect(placement.x).toBeLessThan(0);
|
|
expect(placement.visible).toBe(true);
|
|
});
|
|
|
|
it('packs overlapping lanes on the same side without moving independent lanes', () => {
|
|
const placements = packActivityLaneScreenRects([
|
|
{ id: 'lane-a', side: 'right', x: 400, y: 100, width: 296, height: 220 },
|
|
{ id: 'lane-b', side: 'right', x: 420, y: 150, width: 296, height: 220 },
|
|
{ id: 'lane-c', side: 'left', x: 120, y: 150, width: 296, height: 220 },
|
|
]);
|
|
|
|
expect(placements.get('lane-a')).toEqual({ x: 400, y: 100 });
|
|
expect(placements.get('lane-b')).toEqual({ x: 420, y: 328 });
|
|
expect(placements.get('lane-c')).toEqual({ x: 120, y: 150 });
|
|
});
|
|
|
|
it('packs world lanes globally even when they came from different legacy sides', () => {
|
|
const placements = packActivityLaneWorldRects([
|
|
{ id: 'lane-a', side: 'left', x: 100, y: 100, width: 296, height: 220 },
|
|
{ id: 'lane-b', side: 'right', x: 120, y: 140, width: 296, height: 220 },
|
|
]);
|
|
|
|
expect(placements.get('lane-a')).toEqual({ x: 100, y: 100 });
|
|
expect(placements.get('lane-b')).toEqual({ x: 120, y: 328 });
|
|
});
|
|
|
|
it('tracks graph zoom so activity lanes behave like world elements', () => {
|
|
const placement = getActivityAnchorScreenPlacement({
|
|
anchorX: 40,
|
|
anchorY: 60,
|
|
cameraX: 0,
|
|
cameraY: 0,
|
|
zoom: 4,
|
|
viewportWidth: 800,
|
|
viewportHeight: 600,
|
|
});
|
|
|
|
expect(placement.scale).toBe(4);
|
|
});
|
|
});
|