fix(layout): auto-réparer l'onglet actif
stale
Le backend renvoie l'id actif autoritaire et le
frontend l'adopte pour éviter de rejouer un layout
disparu après overwrite externe.
Co-Authored-By: Claude Opus 4.8
<noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,12 @@ 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 } from "@/adapters/mock";
|
||||
import {
|
||||
MockLayoutGateway,
|
||||
MockAgentGateway,
|
||||
MockTerminalGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { LayoutTabs } from "./LayoutTabs";
|
||||
|
||||
@ -14,11 +19,13 @@ 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(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -133,4 +140,83 @@ describe("LayoutTabs", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user