From c7185ece804f63fc889f55a19c443f86c2552605 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 23:33:28 -0700 Subject: [PATCH] =?UTF-8?q?test(canvas):=20unit=20tests=20for=20A2AEdge=20?= =?UTF-8?q?=E2=80=94=20selection=20+=20Activity-tab=20routing=20(#2071)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Molecule-Platform-Evolvement-Manager] Closes the second item from #2071 (Canvas test gaps follow-up): adds behavioural coverage for the custom React Flow edge that renders delegation counts between workspaces and routes a click into the source workspace's Activity feed. 10 cases across 2 buckets: **Render (6):** - Empty label → BaseEdge only, NO portaled HTML pill (the most common state for cold edges; pill must not render-through-empty) - Non-empty label → pill renders with the exact label text - isHot=true → violet accent classes; blue accent NOT present - isHot=false → blue accent classes - ARIA pluralization: count=1 → "1 delegation from …" (singular) - ARIA pluralization: count=7 → "7 delegations from …" (plural) **Click behaviour (4):** - Click → selectNode(source) - FRESH selection (selectedNodeId != source) → also setPanelTab("activity") - RE-click of already-selected source → setPanelTab MUST NOT fire (this is the regression-locked guarantee — preserves the user's current tab when they intentionally moved to Chat / Memory while inspecting the same peer) - stopPropagation: parent onClick must NOT see the event (otherwise the canvas Pane's clear-selection handler would fire and undo the edge's own selectNode call) ## Mocking strategy - `@xyflow/react`: BaseEdge → , EdgeLabelRenderer → inline pass-through (no portal), getBezierPath → fixed [path, x, y]. Lets the test render the component without a ReactFlow provider. - `@/store/canvas`: vi.hoisted-shared mock state with selectNode + setPanelTab spies and a mutable selectedNodeId. The store's getState() returns the same object so the click handler's `useCanvasStore.getState().selectedNodeId` lookup works. Pattern matches the existing `A2ATopologyOverlay.test.tsx` setup in the same module. ## Test plan - [x] All 10 cases pass locally (`vitest run A2AEdge.test.tsx` — ~1.3s) - [x] No changes to the SUT — pure additive coverage - [ ] CI green ## Remaining #2071 items - OrgCancelButton tests - useDragHandlers tests Each is a separate PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../canvas/__tests__/A2AEdge.test.tsx | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 canvas/src/components/canvas/__tests__/A2AEdge.test.tsx diff --git a/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx b/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx new file mode 100644 index 00000000..51605793 --- /dev/null +++ b/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +/** + * Tests for A2AEdge — the custom React Flow edge that renders the + * delegation count between two workspaces and routes a click into the + * source workspace's Activity feed. + * + * Behavioural coverage for the four contracts the component owns: + * 1. No label → render only the BaseEdge SVG, NO portaled HTML pill + * (renders nothing visible at the label layer) + * 2. Click the pill → selectNode(source) AND setPanelTab("activity") + * when this is a *fresh* selection + * 3. Click the pill on an already-selected source → selectNode(source) + * runs but setPanelTab is NOT called (preserves the user's current + * tab so they don't get yanked off Chat / Memory) + * 4. isHot toggles the violet vs. blue accent classes — locks the + * buildA2AEdges output → A2AEdge styling contract + * 5. ARIA label pluralizes correctly (1 delegation vs N delegations) + * + * Issue: #2071 (Canvas test gaps follow-up). + */ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from "vitest"; +import { + render, + screen, + cleanup, + fireEvent, +} from "@testing-library/react"; + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── + +// @xyflow/react is mocked end-to-end so the test doesn't need a +// ReactFlow provider / Pane / canvas. EdgeLabelRenderer normally +// portals into the canvas root; here it just renders children inline +// so screen.queryByRole picks up the pill. +vi.mock("@xyflow/react", () => ({ + BaseEdge: ({ id }: { id: string }) => , + EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + getBezierPath: () => ["M0,0 L1,1", 50, 50], +})); + +// Canvas store mock — simulates selectNode + setPanelTab + the +// selectedNodeId state the click handler reads via getState(). +const { mockSelectNode, mockSetPanelTab, mockState } = vi.hoisted(() => ({ + mockSelectNode: vi.fn(), + mockSetPanelTab: vi.fn(), + mockState: { + selectedNodeId: null as string | null, + selectNode: vi.fn(), + setPanelTab: vi.fn(), + }, +})); + +// Wire the hoisted mock fns onto the store-state object (vi.hoisted +// returns referentially-stable objects, so this assignment after +// hoisting is observed by every consumer). +mockState.selectNode = mockSelectNode; +mockState.setPanelTab = mockSetPanelTab; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (selector: (s: typeof mockState) => unknown) => selector(mockState), + { getState: () => mockState }, + ), +})); + +// Import the SUT after the mocks are declared. +import { A2AEdge } from "../A2AEdge"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function defaultEdgeProps(over: Record = {}) { + return { + id: "edge-1", + source: "ws-source", + target: "ws-target", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: "right", + targetPosition: "left", + style: {}, + ...over, + } as never; // EdgeProps is a discriminated union; cast simplifies the test fixture +} + +beforeEach(() => { + mockSelectNode.mockReset(); + mockSetPanelTab.mockReset(); + mockState.selectedNodeId = null; +}); + +afterEach(() => { + cleanup(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("A2AEdge — render", () => { + it("renders the BaseEdge but no pill when data.label is empty", () => { + render( + , + ); + expect(screen.getByTestId("base-edge-edge-1")).toBeTruthy(); + // No clickable pill rendered. + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders the pill with the label text when data.label is present", () => { + render( + , + ); + const btn = screen.getByRole("button"); + expect(btn.textContent).toBe("5 calls · 2m ago"); + }); + + it("applies the violet accent classes when isHot is true", () => { + render( + , + ); + const btn = screen.getByRole("button"); + expect(btn.className).toContain("border-violet-500/60"); + expect(btn.className).toContain("text-violet-200"); + // Negative: blue accent must NOT appear when hot + expect(btn.className).not.toContain("border-blue-500/60"); + }); + + it("applies the blue accent classes when isHot is false", () => { + render( + , + ); + const btn = screen.getByRole("button"); + expect(btn.className).toContain("border-blue-500/60"); + expect(btn.className).toContain("text-blue-200"); + }); + + it("ARIA label pluralizes (singular)", () => { + render( + , + ); + const btn = screen.getByRole("button"); + // count=1 → "1 delegation from ." (no trailing s) + expect(btn.getAttribute("aria-label")).toMatch(/^1 delegation from/); + expect(btn.getAttribute("aria-label")).not.toMatch(/^1 delegations/); + }); + + it("ARIA label pluralizes (plural)", () => { + render( + , + ); + const btn = screen.getByRole("button"); + expect(btn.getAttribute("aria-label")).toMatch(/^7 delegations from/); + }); +}); + +describe("A2AEdge — click behaviour", () => { + it("click on the pill selects the source workspace", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button")); + expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha"); + }); + + it("on FRESH selection, also switches the panel tab to Activity", () => { + // Pre-state: nothing selected. The click should yank the user + // into Activity to expose the discovery affordance. + mockState.selectedNodeId = null; + render( + , + ); + fireEvent.click(screen.getByRole("button")); + expect(mockSetPanelTab).toHaveBeenCalledWith("activity"); + }); + + it("on RE-CLICK of an already-selected source, does NOT switch the tab", () => { + // Pre-state: source is already selected; user may have intentionally + // switched to Chat / Memory. Re-clicking the edge must NOT yank them + // back to Activity. (Selector-store getState() returns mockState.) + mockState.selectedNodeId = "ws-alpha"; + render( + , + ); + fireEvent.click(screen.getByRole("button")); + // selectNode still fires (cheap, idempotent on the same id). + expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha"); + // setPanelTab MUST NOT — that's the regression-locked guarantee. + expect(mockSetPanelTab).not.toHaveBeenCalled(); + }); + + it("click stops propagation so the canvas pane doesn't deselect", () => { + // Without stopPropagation, clicking the edge label would bubble + // to the canvas Pane and (per existing handlers) clear the + // selection — exactly the opposite of what the click is meant to do. + const paneClick = vi.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByRole("button")); + expect(paneClick).not.toHaveBeenCalled(); + expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha"); + }); +});