Files
IdeA/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

574 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Ticket #48 — cell controls must stay above in-cell failure/busy overlays.
*
* The regression was visual but user-facing: once a cell showed a terminal
* launch failure banner or the F3 busy veil, the agent selector / split / close
* controls could fall under it and become unusable. These tests keep the real
* LayoutGrid/LeafView composition and stub only xterm, as the neighboring layout
* tests do under jsdom.
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import {
MOCK_REFERENCE_PROFILES,
MockAgentGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { AnnouncementsProvider } from "@/features/announcements";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
interface GridSetup {
agentId: string;
otherAgentId: string;
bId: string;
gateways: Gateways;
layout: MockLayoutGateway;
system: MockSystemGateway;
workState: MockWorkStateGateway;
}
async function makeSplitAgentGrid(): Promise<GridSetup> {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]);
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "mock-claude",
});
const otherAgent = await agentGateway.createAgent("p1", {
name: "Reviewer",
profileId: "mock-claude",
});
const initial = await layout.loadLayout("p1");
const aId = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: aId,
direction: "row",
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: "b",
agent: agent.id,
});
return {
agentId: agent.id,
otherAgentId: otherAgent.id,
bId: "b",
gateways: {
layout,
agent: agentGateway,
profile: profileGateway,
terminal,
system,
workState,
} as unknown as Gateways,
layout,
system,
workState,
};
}
function renderGrid(
gateways: Gateways,
props: Partial<ComponentProps<typeof LayoutGrid>> = {},
) {
return render(
<DIProvider gateways={gateways}>
<AnnouncementsProvider projectId="p1">
<LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
</AnnouncementsProvider>
</DIProvider>,
);
}
function busy(agentId: string, isBusy: boolean): DomainEvent {
return { type: "agentBusyChanged", agentId, busy: isBusy };
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function controlsFor(cellId: string) {
const selector = screen.getByLabelText(`Sélecteur d’agent ${cellId}`);
const controls = selector.parentElement as HTMLElement;
return {
selector,
controls,
splitColumns: screen.getByLabelText(`Diviser ${cellId} en colonnes`),
close: screen.getByLabelText(`Fermer ${cellId}`),
};
}
type CellAction = "selector" | "split" | "close";
function performAction(action: CellAction, cellId: string) {
const { selector, splitColumns, close } = controlsFor(cellId);
if (action === "selector") {
fireEvent.change(selector, { target: { value: "" } });
} else if (action === "split") {
fireEvent.click(splitColumns);
} else {
fireEvent.click(close);
}
}
function expectedOperation(action: CellAction, cellId: string) {
if (action === "selector") {
return expect.objectContaining({
type: "setCellAgent",
target: cellId,
agent: null,
});
}
if (action === "split") {
return expect.objectContaining({
type: "split",
target: cellId,
direction: "row",
});
}
return expect.objectContaining({
type: "merge",
container: "c",
keepIndex: 0,
});
}
beforeEach(() => {
window.localStorage.clear();
});
describe("LayoutGrid — ticket #48 cell control layering", () => {
it("shows restart only for an agent cell and disables its inputs while scheduled", async () => {
const setup = await makeSplitAgentGrid();
const restart = vi
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
.mockResolvedValue({
restartId: "restart-cell-1",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
const button = await screen.findByRole("button", { name: "Redémarrer l’agent" });
expect(screen.getAllByRole("button", { name: "Redémarrer l’agent" })).toHaveLength(1);
fireEvent.click(button);
await waitFor(() =>
expect(restart).toHaveBeenCalledWith({
projectId: "p1",
nodeId: setup.bId,
agentId: setup.agentId,
presentation: "tui",
}),
);
expect(
(screen.getByRole("button", {
name: "Redémarrage de l’agent…",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`) as HTMLSelectElement)
.disabled,
).toBe(true);
expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy();
await flush();
act(() => {
setup.system.emit({
type: "agentRestartCompleted",
agentId: setup.agentId,
restartId: "restart-cell-1",
});
});
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Redémarrer l’agent",
}) as HTMLButtonElement).disabled,
).toBe(false),
);
});
it("confirms a busy restart and forwards the custom CLI presentation", async () => {
const setup = await makeSplitAgentGrid();
window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom");
setup.workState._setProjectWorkState("p1", {
agents: [
{
agentId: setup.agentId,
name: "Worker",
profileId: "mock-claude",
busy: { state: "busy", sinceMs: 1 },
},
],
});
const restart = vi
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
.mockResolvedValue({
restartId: "restart-cell-2",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "structured",
presentation: "customCli",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
const dialog = await screen.findByRole("alertdialog", {
name: "Redémarrer l’agent ?",
});
expect(restart).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Interrompre et redémarrer" }));
await waitFor(() =>
expect(restart).toHaveBeenCalledWith({
projectId: "p1",
nodeId: setup.bId,
agentId: setup.agentId,
presentation: "customCli",
}),
);
expect(dialog.isConnected).toBe(false);
window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`);
});
it("distinguishes an asynchronous relaunch failure and offers retry/close", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
restartId: "restart-cell-3",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
await flush();
act(() => {
setup.system.emit({
type: "agentRestartFailed",
agentId: setup.agentId,
restartId: "restart-cell-3",
phase: "launching",
code: "LAUNCH_FAILED",
message: "provider unavailable",
});
});
expect(await screen.findByText("Impossible de relancer l’agent.")).toBeTruthy();
expect(screen.getByText("provider unavailable")).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Fermer" }));
expect(screen.queryByTestId("cell-agent-restart-state")).toBeNull();
});
it("distinguishes an asynchronous stop failure", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
restartId: "restart-cell-stop",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
await flush();
act(() => {
setup.system.emit({
type: "agentRestartFailed",
agentId: setup.agentId,
restartId: "restart-cell-stop",
phase: "stopping",
code: "STOP_TIMEOUT",
message: "stop timed out",
});
});
expect(await screen.findByText("Impossible d’arrêter l’agent.")).toBeTruthy();
});
it("surfaces a synchronous typed binding error", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockRejectedValue({
code: "AGENT_NOT_BOUND_TO_CELL",
message: "stale cell binding",
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
expect(
await screen.findByText("L’agent n’est plus associé à cette cellule."),
).toBeTruthy();
expect(screen.getByText("stale cell binding")).toBeTruthy();
});
it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => {
const setup = await makeSplitAgentGrid();
renderGrid(setup.gateways);
const group = await screen.findByRole("group", {
name: `Mode CLI de l’agent ${setup.bId}`,
});
const { controls } = controlsFor(setup.bId);
expect(group.textContent).toBe("TUI nativeCLI custom");
expect(group.style.maxWidth).toBe("128px");
expect(controls.style.left).toBe("4px");
expect(controls.style.right).toBe("16px");
expect(controls.style.justifyContent).toBe("flex-end");
expect(controls.style.overflow).toBe("hidden");
});
it("keeps cell controls available over a busy custom CLI surface (#249)", async () => {
const setup = await makeSplitAgentGrid();
window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom");
setup.workState._setProjectWorkState("p1", {
agents: [
{
agentId: setup.agentId,
name: "Worker",
profileId: "mock-claude",
busy: { state: "busy", sinceMs: 1 },
},
],
});
renderGrid(setup.gateways);
await screen.findByTestId("custom-agent-chat-view");
window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`);
const customToolbar = screen.getByRole("toolbar", {
name: "custom agent chat actions",
});
const mutate = vi.spyOn(setup.layout, "mutateLayout");
expect(customToolbar.className).toContain("z-20");
expect(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`)).toBeTruthy();
expect(screen.getByRole("group", {
name: `Mode CLI de l’agent ${setup.bId}`,
})).toBeTruthy();
expect(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)).toBeTruthy();
expect(screen.getByLabelText(`Diviser ${setup.bId} en lignes`)).toBeTruthy();
expect(screen.getByLabelText(`Fermer ${setup.bId}`)).toBeTruthy();
expect(screen.queryByRole("button", {
name: `cancel current turn ${setup.bId}`,
})).toBeNull();
expect(screen.queryByText("Busy")).toBeNull();
fireEvent.click(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`));
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(
"p1",
expect.objectContaining({
type: "split",
target: setup.bId,
direction: "row",
}),
undefined,
),
);
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
fireEvent.change(selector, { target: { value: setup.otherAgentId } });
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(
"p1",
expect.objectContaining({
type: "setCellAgent",
target: setup.bId,
agent: setup.otherAgentId,
}),
undefined,
),
);
});
it("hides busy controls while keeping persistent cell controls and History removed (#277)", async () => {
const setup = await makeSplitAgentGrid();
await setup.layout.mutateLayout("p1", {
type: "setCellConversation",
target: setup.bId,
conversationId: "prior-conv",
});
setup.workState._setProjectWorkState("p1", {
agents: [
{
agentId: setup.agentId,
name: "Worker",
profileId: "mock-claude",
busy: { state: "busy", sinceMs: 1 },
},
],
});
renderGrid(setup.gateways, { onOpenConversation: vi.fn() });
await flush();
expect(screen.queryByRole("button", {
name: `cancel current turn ${setup.bId}`,
})).toBeNull();
expect(screen.queryByText("Busy")).toBeNull();
const { controls } = controlsFor(setup.bId);
expect(controls.style.zIndex).toBe("5");
expect(screen.queryByRole("button", {
name: `open cell conversation ${setup.bId}`,
})).toBeNull();
expect(screen.queryByText("History")).toBeNull();
});
it.each<CellAction>(["selector", "split", "close"])(
"keeps %s above/clickable over a terminal launch error",
async (action) => {
const setup = await makeSplitAgentGrid();
const agentGateway = setup.gateways.agent as MockAgentGateway;
vi.spyOn(agentGateway, "launchAgent").mockRejectedValue(
new Error("profile CLI failed to start"),
);
const mutate = vi.spyOn(setup.layout, "mutateLayout");
renderGrid(setup.gateways);
const error = await screen.findByTestId("terminal-error");
const { controls } = controlsFor(setup.bId);
expect(controls.style.zIndex).toBe("5");
expect(error.style.zIndex).toBe("3");
performAction(action, setup.bId);
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(
"p1",
expectedOperation(action, setup.bId),
undefined,
),
);
},
);
it.each<CellAction>(["selector", "split", "close"])(
"keeps %s above/clickable over the F3 busy overlay",
async (action) => {
const setup = await makeSplitAgentGrid();
const mutate = vi.spyOn(setup.layout, "mutateLayout");
renderGrid(setup.gateways);
await flush();
act(() => setup.system.emit(busy(setup.agentId, true)));
const overlay = await screen.findByTestId("target-announcements-overlay");
const { controls } = controlsFor(setup.bId);
expect(controls.style.zIndex).toBe("5");
expect(overlay.className).toContain("z-[4]");
expect(overlay.className).toContain("pointer-events-none");
performAction(action, setup.bId);
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(
"p1",
expectedOperation(action, setup.bId),
undefined,
),
);
},
);
it("gives the native TUI restart overlay priority over other cell veils", async () => {
const setup = await makeSplitAgentGrid();
renderGrid(setup.gateways);
await flush();
act(() => {
setup.system.emit(busy(setup.agentId, true));
setup.system.emit({
type: "agentRestartRequested",
agentId: setup.agentId,
restartId: "restart-1",
reason: null,
});
});
const overlay = await screen.findByTestId("agent-restart-overlay");
expect(overlay.className).toContain("z-[4]");
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
expect(screen.queryByText("------- redemarrage de l'agent -------")).toBeNull();
expect(controlsFor(setup.bId).controls.style.zIndex).toBe("5");
});
});