/** * #4 — LayoutTabs / useLayouts: named-layout tab bar driven through the * MockLayoutGateway. Covers: list, create, rename, delete, switch. */ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import type { Gateways } from "@/ports"; import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway, MockSystemGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { LayoutTabs } from "./LayoutTabs"; function renderTabs( layout: MockLayoutGateway, projectId = "p1", onActiveLayoutChange = vi.fn(), system?: MockSystemGateway, ) { const gateways = { layout, terminal: new MockTerminalGateway(), agent: new MockAgentGateway(), ...(system ? { system } : {}), } as unknown as Gateways; return render( , ); } describe("LayoutTabs", () => { it("renders the default 'Default' tab on first load", async () => { const layout = new MockLayoutGateway(); renderTabs(layout); await waitFor(() => { expect(screen.getByText("Default")).toBeTruthy(); }); }); it("shows a create (+) button", async () => { const layout = new MockLayoutGateway(); renderTabs(layout); await waitFor(() => { expect(screen.getByLabelText("create layout")).toBeTruthy(); }); }); it("switching tabs reports the new active layout (id + kind) to the parent", async () => { const layout = new MockLayoutGateway(); // Seed a second layout. await layout.createLayout("p1", "Beta"); const onActiveLayoutChange = vi.fn(); renderTabs(layout, "p1", onActiveLayoutChange); await waitFor(() => { expect(screen.getByText("Beta")).toBeTruthy(); }); // Click the Beta tab. fireEvent.click(screen.getByText("Beta")); await waitFor(() => { // Called with the full LayoutInfo (so the parent knows the kind too). expect(onActiveLayoutChange).toHaveBeenCalledWith( expect.objectContaining({ name: "Beta", kind: "terminal" }), ); }); }); it("renaming a tab (double-click → type → blur) updates the name", async () => { const layout = new MockLayoutGateway(); renderTabs(layout); await waitFor(() => { expect(screen.getByText("Default")).toBeTruthy(); }); // Double-click to start rename. fireEvent.dblClick(screen.getByText("Default")); await waitFor(() => { expect(screen.getByLabelText(/rename layout/i)).toBeTruthy(); }); const input = screen.getByLabelText(/rename layout/i) as HTMLInputElement; fireEvent.change(input, { target: { value: "My Layout" } }); fireEvent.blur(input); await waitFor(() => { expect(screen.getByText("My Layout")).toBeTruthy(); }); }); it("delete button is disabled when only one layout exists", async () => { const layout = new MockLayoutGateway(); renderTabs(layout); await waitFor(() => { expect(screen.getByLabelText(/delete layout/i)).toBeTruthy(); }); const closeBtn = screen.getByLabelText(/delete layout/i) as HTMLButtonElement; expect(closeBtn.disabled).toBe(true); }); it("creating a layout adds a tab", async () => { const layout = new MockLayoutGateway(); // Directly create a second layout via the gateway (simulates the + flow). await layout.createLayout("p1", "Extra"); renderTabs(layout); await waitFor(() => { expect(screen.getByText("Default")).toBeTruthy(); expect(screen.getByText("Extra")).toBeTruthy(); }); }); it("deleting a layout removes its tab (when 2 exist)", async () => { const layout = new MockLayoutGateway(); await layout.createLayout("p1", "Second"); renderTabs(layout); await waitFor(() => { expect(screen.getByText("Second")).toBeTruthy(); }); const deleteBtns = screen.getAllByLabelText(/delete layout/i); // Both tabs exist, pick the second (Second). await act(async () => { fireEvent.click(deleteBtns[1]); }); await waitFor(() => { expect(screen.queryByText("Second")).toBeNull(); }); }); // ── Active-layout self-heal (feature/layouts-active-self-heal) ────────────── it("switching tabs adopts the backend's authoritative active id", async () => { const layout = new MockLayoutGateway(); const { layoutId: betaId } = await layout.createLayout("p1", "Beta"); const onActiveLayoutChange = vi.fn(); renderTabs(layout, "p1", onActiveLayoutChange); await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy()); await act(async () => { fireEvent.click(screen.getByText("Beta")); }); await waitFor(() => { expect(onActiveLayoutChange).toHaveBeenCalledWith( expect.objectContaining({ id: betaId, name: "Beta" }), ); }); }); it("on layoutChanged it re-lists and purges a vanished active id (self-heal)", async () => { const layout = new MockLayoutGateway(); const system = new MockSystemGateway(); const { layoutId: betaId } = await layout.createLayout("p1", "Beta"); const onActiveLayoutChange = vi.fn(); renderTabs(layout, "p1", onActiveLayoutChange, system); await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy()); // Make Beta the active layout from the UI. await act(async () => { fireEvent.click(screen.getByText("Beta")); }); await waitFor(() => expect(onActiveLayoutChange).toHaveBeenCalledWith( expect.objectContaining({ id: betaId }), ), ); // Simulate an EXTERNAL overwrite of layouts.json: Beta is removed and the // backend active id falls back to Default — done straight on the gateway, // bypassing the hook, so the hook still locally remembers the (now stale) id. const { layouts } = await layout.listLayouts("p1"); const defaultId = layouts.find((l) => l.name === "Default")!.id; await layout.deleteLayout("p1", betaId); onActiveLayoutChange.mockClear(); // The backend notifies the change → the hook re-lists and purges the stale id. await act(async () => { system.emit({ type: "layoutChanged", projectId: "p1" }); }); await waitFor(() => { expect(screen.queryByText("Beta")).toBeNull(); expect(onActiveLayoutChange).toHaveBeenCalledWith( expect.objectContaining({ id: defaultId, name: "Default" }), ); }); }); it("ignores layoutChanged events for another project", async () => { const layout = new MockLayoutGateway(); const system = new MockSystemGateway(); await layout.createLayout("p1", "Beta"); renderTabs(layout, "p1", vi.fn(), system); await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy()); // An event for a different project must not trigger a re-list/purge here. await act(async () => { system.emit({ type: "layoutChanged", projectId: "other-project" }); }); // Both tabs are still present (no spurious reconciliation). expect(screen.getByText("Beta")).toBeTruthy(); expect(screen.getByText("Default")).toBeTruthy(); }); });