feat(FN-3472): expand org chart regression test assertions

The merge adds expanded regression assertions for the org chart feature, covering agent CSS classes and mobile agents view components. The tests verify styling and layout behavior across both desktop and mobile contexts.

Fusion-Task-Id: FN-3472
This commit is contained in:
Fusion
2026-05-05 07:05:09 -07:00
committed by gsxdsm
parent b8a32f8765
commit 0bc75914e8
7 changed files with 321 additions and 6 deletions

View File

@@ -104,6 +104,7 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
expect(hasClass(".agent-org-chart--vertical")).toBe(true);
expect(hasClass(".agent-board")).toBe(true);
expect(hasClass(".agent-board-card")).toBe(true);
expect(hasClass(".agent-board-card--idle")).toBe(true);
@@ -191,10 +192,14 @@ describe("Agent CSS classes", () => {
expect(orgChartSection).toContain("gap: var(--space-xl)");
expect(orgChartSection).toContain("padding: var(--space-lg)");
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
expect(orgChartSection).toContain("--org-chart-root-gap: var(--space-xl)");
expect(orgChartSection).toContain("--org-chart-connector-gap: var(--space-sm)");
expect(orgChartSection).toContain("--org-chart-sibling-gap: var(--space-xl)");
expect(orgChartSection).toContain("--org-chart-children-offset: calc(var(--space-lg) + var(--space-sm))");
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
expect(orgChartSection).toContain("overflow: auto");
expect(orgChartSection).toContain("overscroll-behavior: contain");
expect(orgChartSection).toContain("transform-origin: top left");
expect(orgChartSection).toContain("border: 1px solid var(--border)");
expect(orgChartSection).toContain("color: var(--text)");
@@ -206,7 +211,14 @@ describe("Agent CSS classes", () => {
expect(orgChartSection).not.toContain("var(--border-color)");
expect(orgChartSection).not.toContain("var(--text-primary)");
expect(orgChartSection).not.toContain("var(--text-secondary)");
expect(orgChartSection).not.toMatch(/1\.5rem|0\.75rem|0\.72rem|0\.78rem|0\.65rem|120ms\s+ease|220px|10px/);
expect(orgChartSection).toContain(".agent-org-chart--vertical .org-chart-children");
expect(orgChartSection).toContain("gap: var(--space-sm)");
expect(orgChartSection).toContain("padding-top: var(--space-sm)");
expect(orgChartSection).toContain("padding-left: calc(var(--space-lg) + var(--space-sm))");
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
expect(orgChartSection).toContain("--org-chart-sibling-gap: var(--space-sm)");
expect(orgChartSection).toContain("--org-chart-children-offset: var(--space-lg)");
expect(orgChartSection).not.toMatch(/1\.5rem|0\.75rem|0\.72rem|0\.78rem|0\.65rem|120ms\s+ease|10px/);
});
// Verify AgentDetailView classes

View File

@@ -783,8 +783,11 @@
/* === FN-1167: Agent Org Chart + Chain of Command === */
.agent-org-chart-shell {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: var(--space-sm);
overflow: hidden;
}
.agent-org-chart-controls {
@@ -803,6 +806,8 @@
}
.agent-org-chart-viewport {
flex: 1;
min-height: 0;
overflow: auto;
max-width: 100%;
overscroll-behavior: contain;
@@ -837,10 +842,11 @@
--org-chart-sibling-gap: var(--space-xl);
--org-chart-connector-gap: var(--space-sm);
--org-chart-children-offset: calc(var(--space-lg) + var(--space-sm));
--org-chart-root-gap: var(--space-xl);
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: var(--space-xl);
gap: var(--org-chart-root-gap);
padding: var(--space-lg);
min-height: var(--org-chart-node-width);
}
@@ -867,6 +873,10 @@
flex: 0 0 auto;
}
.org-chart-node--has-children {
padding-bottom: var(--space-xs);
}
.org-chart-node-card {
width: var(--org-chart-node-width);
max-width: 100%;
@@ -952,7 +962,7 @@
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: center;
width: 100%;
gap: var(--org-chart-sibling-gap);
padding-top: var(--org-chart-children-offset);
@@ -969,6 +979,59 @@
background: var(--border);
}
.agent-org-chart--vertical {
flex-direction: column;
gap: var(--space-md);
min-height: auto;
}
.agent-org-chart--vertical .org-chart-node {
width: 100%;
min-width: var(--org-chart-node-width);
align-items: flex-start;
}
.agent-org-chart--vertical .org-chart-node--has-children {
padding-bottom: 0;
}
.agent-org-chart--vertical .org-chart-node-card {
margin-left: 0;
}
.agent-org-chart--vertical .org-chart-node--has-children > .org-chart-node-card::after {
left: calc(var(--space-lg) + var(--space-xs));
transform: none;
}
.agent-org-chart--vertical .org-chart-children {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
gap: var(--space-sm);
padding-top: var(--space-sm);
padding-left: calc(var(--space-lg) + var(--space-sm));
margin-top: 0;
}
.agent-org-chart--vertical .org-chart-children::before {
top: 0;
bottom: var(--space-sm);
left: var(--space-sm);
right: auto;
width: 1px;
height: auto;
}
.agent-org-chart--vertical .org-chart-children > .org-chart-node::before {
top: calc(var(--space-md) * -1);
left: calc(var(--space-sm) * -1);
transform: none;
width: var(--space-sm);
height: 1px;
}
.org-chart-children > .org-chart-node::before {
content: "";
position: absolute;
@@ -1058,6 +1121,8 @@
.agent-org-chart-shell {
min-height: 0;
flex: 1;
overflow: hidden;
}
.agent-org-chart-controls {
@@ -1073,6 +1138,15 @@
.agent-org-chart-viewport {
min-height: calc(var(--space-2xl) * 4);
flex: 1;
min-width: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.agent-org-chart-canvas {
min-width: max-content;
}
.agent-org-chart {
@@ -1084,6 +1158,10 @@
gap: var(--space-sm);
}
.agent-org-chart--vertical {
min-width: 100%;
}
.agents-view-loading {
min-height: calc(var(--space-2xl) * 4);
}

View File

@@ -26,6 +26,7 @@ import {
} from "../utils/heartbeatIntervals";
import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "./agentsOrgChartLayout";
export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void;
@@ -247,10 +248,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
});
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
const controlsPanelRef = useRef<HTMLDivElement>(null);
const orgChartViewportRef = useRef<HTMLDivElement>(null);
const { confirm } = useConfirm();
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
const controlsPanelId = useId();
@@ -371,6 +374,26 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
.filter((n): n is OrgTreeNode => n !== null);
}, [orgTree, showSystemAgents]);
useEffect(() => {
if (agentView !== "org") return;
const viewport = orgChartViewportRef.current;
if (!viewport) return;
const updateWidth = () => {
setOrgChartViewportWidth(viewport.clientWidth);
};
updateWidth();
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateWidth) : null;
resizeObserver?.observe(viewport);
window.addEventListener("resize", updateWidth);
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", updateWidth);
};
}, [agentView, displayOrgTree.length]);
useEffect(() => {
if (agentView !== "org") return;
@@ -712,6 +735,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({
tree: displayOrgTree,
availableWidth: orgChartViewportWidth,
}), [displayOrgTree, orgChartViewportWidth]);
/** Get skill badges from agent metadata */
const getSkillBadges = (agent: Agent): string[] => {
@@ -1027,9 +1054,17 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
</button>
</div>
) : null}
<div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
<div
ref={orgChartViewportRef}
className="agent-org-chart-viewport"
data-testid="agent-org-chart-viewport"
>
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
<div className="agent-org-chart" data-testid="agent-org-chart">
<div
className={`agent-org-chart${orgChartLayoutMode === "vertical" ? " agent-org-chart--vertical" : ""}`}
data-testid="agent-org-chart"
data-layout-mode={orgChartLayoutMode}
>
{isOrgTreeLoading ? (
<div className="agent-org-chart__loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />

View File

@@ -66,6 +66,8 @@ const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats);
const mockFetchSettings = vi.mocked((apiModule as any).fetchSettings);
const mockUpdateSettings = vi.mocked((apiModule as any).updateSettings);
const mockClipboardWriteText = vi.fn();
const mockResizeObserverObserve = vi.fn();
const mockResizeObserverDisconnect = vi.fn();
describe("AgentsView", () => {
const mockAddToast = vi.fn();
@@ -121,6 +123,10 @@ describe("AgentsView", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("ResizeObserver", class {
observe = mockResizeObserverObserve;
disconnect = mockResizeObserverDisconnect;
});
mockViewportMode.mockReturnValue("desktop");
mockClipboardWriteText.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
@@ -1308,6 +1314,41 @@ describe("AgentsView", () => {
expect(container.querySelectorAll(".org-chart-node--has-children").length).toBeGreaterThan(0);
});
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
const chart = screen.getByTestId("agent-org-chart");
expect(chart.getAttribute("data-layout-mode")).toBe("vertical");
expect(chart.className).toContain("agent-org-chart--vertical");
});
fireEvent.click(screen.getByText("Director One"));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
});
clientWidthSpy.mockRestore();
});
it("keeps org chart horizontal layout mode when viewport is wide enough", async () => {
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1920);
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
const chart = screen.getByTestId("agent-org-chart");
expect(chart.getAttribute("data-layout-mode")).toBe("horizontal");
expect(chart.className).not.toContain("agent-org-chart--vertical");
});
clientWidthSpy.mockRestore();
});
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchOrgTree.mockResolvedValue(orgTree);

View File

@@ -59,6 +59,34 @@ import {
fetchOrgTree,
} from "../../api";
const mockOrgTree = [
{
agent: {
id: "agent-root-mobile",
name: "Mobile Root",
role: "scheduler" as AgentCapability,
state: "active" as AgentState,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
children: [
{
agent: {
id: "agent-child-mobile",
name: "Mobile Child",
role: "executor" as AgentCapability,
state: "running" as AgentState,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
children: [],
},
],
},
];
const mockAgents: Agent[] = [
{
id: "agent-001",
@@ -170,6 +198,7 @@ describe("AgentsView mobile adaptations", () => {
});
it("switches between board, list, and org views", async () => {
vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree);
const { container } = render(<AgentsView addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
@@ -262,12 +291,41 @@ describe("agents-view mobile CSS", () => {
it("defines mobile org chart sizing and pan/zoom controls rules", () => {
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
const viewportBlock = extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport");
expect(viewportBlock).toContain("min-height: calc(var(--space-2xl) * 4)");
expect(viewportBlock).toContain("overflow: auto");
expect(viewportBlock).toContain("overscroll-behavior: contain");
expect(viewportBlock).toContain("-webkit-overflow-scrolling: touch");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-sibling-gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-children-offset: var(--space-lg)");
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node__badge")).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.625)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-shell")).toContain("overflow: hidden");
});
it("keeps org chart viewport as scroll owner while mobile zoom and selection work", async () => {
vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree);
const { container } = render(<AgentsView addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
const shell = await screen.findByTestId("agent-org-chart-shell");
expect(shell.className).toContain("agent-org-chart-shell");
const viewport = await screen.findByTestId("agent-org-chart-viewport");
expect(viewport.className).toContain("agent-org-chart-viewport");
const chart = await screen.findByTestId("agent-org-chart");
expect(chart.getAttribute("data-layout-mode")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
fireEvent.click(screen.getByText("Mobile Child"));
await waitFor(() => {
expect(container.querySelector(".org-chart-node-card--running.agent-card--selected")).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { OrgTreeNode } from "../../api";
import { estimateOrgChartWidth, resolveOrgChartLayoutMode } from "../agentsOrgChartLayout";
function makeNode(id: string, children: OrgTreeNode[] = []): OrgTreeNode {
return {
agent: {
id,
name: id,
role: "executor",
state: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
children,
};
}
describe("agentsOrgChartLayout", () => {
it("returns horizontal when estimated width fits", () => {
const tree = [makeNode("root", [makeNode("child")])];
const width = estimateOrgChartWidth(tree);
expect(resolveOrgChartLayoutMode({ tree, availableWidth: width + 1 })).toBe("horizontal");
});
it("returns vertical when estimated width exceeds available width", () => {
const tree = [
makeNode("root", [
makeNode("a", [makeNode("a1"), makeNode("a2")]),
makeNode("b", [makeNode("b1"), makeNode("b2")]),
]),
makeNode("root-2", [makeNode("c"), makeNode("d")]),
];
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 400 })).toBe("vertical");
});
it("stays horizontal for single-root trees and zero-width measurement", () => {
const tree = [makeNode("solo", [makeNode("child-1"), makeNode("child-2")])];
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 0 })).toBe("horizontal");
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 120 })).toBe("horizontal");
});
});

View File

@@ -0,0 +1,48 @@
import type { OrgTreeNode } from "../api";
export type OrgChartLayoutMode = "horizontal" | "vertical";
export interface OrgChartLayoutInput {
tree: OrgTreeNode[];
availableWidth: number;
}
const DEFAULT_NODE_WIDTH = 220;
const DEFAULT_SIBLING_GAP = 24;
const DEFAULT_ROOT_GAP = 24;
const DEFAULT_CHART_PADDING = 16;
function getLeafCount(node: OrgTreeNode): number {
if (node.children.length === 0) {
return 1;
}
return node.children.reduce((sum, child) => sum + getLeafCount(child), 0);
}
function estimateSubtreeWidth(node: OrgTreeNode): number {
const leaves = getLeafCount(node);
return leaves * DEFAULT_NODE_WIDTH + Math.max(0, leaves - 1) * DEFAULT_SIBLING_GAP;
}
export function estimateOrgChartWidth(tree: OrgTreeNode[]): number {
if (tree.length === 0) {
return DEFAULT_NODE_WIDTH;
}
const rootWidths = tree.map(estimateSubtreeWidth);
const totalRootsWidth = rootWidths.reduce((sum, width) => sum + width, 0);
const rootGapWidth = Math.max(0, tree.length - 1) * DEFAULT_ROOT_GAP;
return totalRootsWidth + rootGapWidth + DEFAULT_CHART_PADDING * 2;
}
export function resolveOrgChartLayoutMode(input: OrgChartLayoutInput): OrgChartLayoutMode {
const safeAvailableWidth = Number.isFinite(input.availableWidth) && input.availableWidth > 0
? input.availableWidth
: 0;
if (safeAvailableWidth === 0 || input.tree.length <= 1) {
return "horizontal";
}
const estimatedWidth = estimateOrgChartWidth(input.tree);
return estimatedWidth > safeAvailableWidth ? "vertical" : "horizontal";
}