/** * L2 — the projects feature wired to the stateful `MockProjectGateway` via the * real `DIProvider`. Covers create → list/tab, open → tab, close → remove, * duplicate root (INVALID) and unknown id (NOT_FOUND) error handling. */ import { describe, it, expect } from "vitest"; import { render, screen, within, waitFor, fireEvent, } from "@testing-library/react"; import { MockAgentGateway, MockDesktopServerGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { ProjectsView } from "./ProjectsView"; /** Renders the view behind a DI provider seeded with a fresh project gateway. */ function renderView( project: MockProjectGateway = new MockProjectGateway(), system: MockSystemGateway = new MockSystemGateway(), ) { // The project gateway drives the primary assertions; agent + profile stubs are // required because ProjectsView now renders AgentsPanel for the active tab. // template gateway is required because ProjectsView now renders TemplatesPanel. // desktopServer is required by Settings → Deployment (#68). const agentGateway = new MockAgentGateway(); const gateways = { system, project, agent: agentGateway, profile: new MockProfileGateway(), template: new MockTemplateGateway(agentGateway), git: new MockGitGateway(), workState: new MockWorkStateGateway(), window: new MockWindowGateway(), desktopServer: new MockDesktopServerGateway(), } as unknown as Gateways; return { project, system, ...render( , ), }; } /** Waits for the initial async refresh to settle (Refresh button re-enabled). */ async function waitForIdle() { await waitFor(() => expect( ( screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement ).disabled, ).toBe(false), ); } /** Fills the create form and submits it (assumes the form is idle/enabled). */ async function createProject(name: string, root: string) { await waitForIdle(); fireEvent.change(screen.getByLabelText("project name"), { target: { value: name }, }); fireEvent.change(screen.getByLabelText("project root"), { target: { value: root }, }); fireEvent.click(screen.getByRole("button", { name: "Create project" })); } function tablist() { return screen.getByRole("tablist"); } /** * Opens a panel floating via the single « Panneaux » menu (#26): click the menu, * click the panel entry (opens its placement submenu), then pick « Flottant ». * `panelTitle` is the panel's human title ("Work state", "Project context", * "Agents", "Git", "Projects", …). */ function openPanel(panelTitle: string) { fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); fireEvent.click(screen.getByRole("button", { name: panelTitle })); fireEvent.click(screen.getByRole("button", { name: "Flottant" })); } /** Clicks an item in a flat top-level menu (e.g. Settings). */ function openMenuItem(menu: string, item: string) { fireEvent.click(screen.getByRole("button", { name: menu })); fireEvent.click(screen.getByRole("button", { name: item })); } describe("ProjectsView (with MockProjectGateway)", () => { it("creating a project adds it to the known list and opens a tab", async () => { renderView(); expect(screen.getByText("No projects yet.")).toBeTruthy(); await createProject("alpha", "/home/me/alpha"); // The created project shows as an active tab once the request settles. const tab = await screen.findByRole("tab"); expect(tab.getAttribute("aria-selected")).toBe("true"); expect(within(tab).getByText("alpha")).toBeTruthy(); // And it is listed in the projects manager (File → Projects…) with its root. openPanel("Projects"); expect( (await screen.findAllByText("/home/me/alpha")).length, ).toBeGreaterThan(0); }); it("opening a project creates a tab and makes it the active one", async () => { // Seed two projects directly through the stateful gateway. const project = new MockProjectGateway(); const a = await project.createProject("alpha", "/p/a"); const b = await project.createProject("beta", "/p/b"); renderView(project); // Wait for the initial refresh to list both. await screen.findByText("/p/a"); await screen.findByText("/p/b"); // Open the projects manager window so the list persists across activation // (the inline welcome list is replaced by the terminal grid once active). openPanel("Projects"); // Open alpha, then beta — re-querying the list after each activation. const openInList = (name: string) => within( screen.getAllByRole("listitem").find((li) => within(li).queryByText(name))!, ).getByRole("button", { name: "Open" }); fireEvent.click(openInList("alpha")); await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(1), ); fireEvent.click(openInList("beta")); await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(2), ); // The last-opened tab (beta) is active. const tabs = within(tablist()).getAllByRole("tab"); const betaTab = tabs.find((t) => within(t).queryByText("beta"))!; const alphaTab = tabs.find((t) => within(t).queryByText("alpha"))!; expect(betaTab.getAttribute("aria-selected")).toBe("true"); expect(alphaTab.getAttribute("aria-selected")).toBe("false"); // Ignore unused binding lint for `a`/`b`. expect([a.id, b.id]).toHaveLength(2); }); it("shows and renders the Work sidebar tab for an active project", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); renderView(project); await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); await screen.findByRole("tab", { name: "alpha" }); openPanel("Work state"); expect(await screen.findByText("No agent work state.")).toBeTruthy(); }); it("switches the active project via the tab-bar + button → Projects panel (#26)", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); await project.createProject("beta", "/p/b"); renderView(project); // Open alpha from the welcome list ⇒ it becomes the active tab. await screen.findByText("/p/a"); const alphaLi = screen .getAllByRole("listitem") .find((n) => within(n).queryByText("alpha"))!; fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" })); await screen.findByRole("tab", { name: "alpha" }); // The "+" button opens the Projects panel (floating); pick beta from its // known-projects list — no verbose "Projet : …" selector anymore. fireEvent.click(screen.getByRole("button", { name: "open projects panel" })); const betaLi = await waitFor(() => screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!, ); fireEvent.click(within(betaLi).getByRole("button", { name: "Open" })); await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); const betaTab = screen .getAllByRole("tab") .find((t) => within(t).queryByText("beta"))!; expect(betaTab.getAttribute("aria-selected")).toBe("true"); }); it("opens the Settings surface from the Settings menu and closes it from the view (#16/#68)", async () => { renderView(); await waitForIdle(); // The project surface (create form) is visible; profiles settings are not. expect(screen.getByLabelText("project name")).toBeTruthy(); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); // Settings → AI Profiles swaps the main area to the profiles settings. openMenuItem("Settings", "AI Profiles"); expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); expect(screen.queryByLabelText("project name")).toBeNull(); // #68: closing is an explicit action in the view, not an alternating menu // label — the label never scaled past one section. fireEvent.click(screen.getByRole("button", { name: "Close Settings" })); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); }); it("navigates between Settings sections from the menu and the nav column (#68)", async () => { renderView(); await waitForIdle(); // The Settings menu now names sections; Deployment opens directly. openMenuItem("Settings", "Deployment"); expect(await screen.findByLabelText("deployment settings")).toBeTruthy(); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); // The internal nav column switches sections without leaving Settings. const nav = screen.getByRole("navigation", { name: "settings sections" }); fireEvent.click(within(nav).getByRole("button", { name: "AI Profiles" })); expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); expect(screen.queryByLabelText("deployment settings")).toBeNull(); }); it("closing a tab removes it from the tab bar", async () => { renderView(); await createProject("alpha", "/home/me/alpha"); await screen.findByRole("tab"); fireEvent.click(screen.getByRole("button", { name: "close alpha" })); await waitFor(() => { expect(screen.getByText("No open tabs.")).toBeTruthy(); }); }); it("creating a project with a duplicate root surfaces an INVALID error", async () => { renderView(); await createProject("alpha", "/dup/root"); await screen.findByRole("tab"); // first project opened as a tab // Reopen the projects manager (the inline form is replaced by the grid once // a project is active), then create a second project at the same root. openPanel("Projects"); await createProject("beta", "/dup/root"); const alert = await screen.findByRole("alert"); expect(alert.textContent).toMatch(/already exists/i); }); it("menu panels show the selected panel and hide others (#16)", async () => { // Set up a project so that agent/template/git panels are reachable. const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); renderView(project); // Open alpha so the panels become active. With a project open the main area // is the terminal grid — panels now live behind the menu bar. await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(1), ); // File → Projects… opens the projects manager window — form inputs visible. openPanel("Projects"); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); // View → Agents — agent form visible; project form window is replaced. openPanel("Agents"); await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy(), ); expect(screen.queryByLabelText("project name")).toBeNull(); // View → Context — shared project context editor visible. openPanel("Project context"); await waitFor(() => expect(screen.getByLabelText("project context")).toBeTruthy(), ); expect(screen.queryByLabelText("agent name")).toBeNull(); // View → Templates — the "New template" button visible. openPanel("Templates"); await waitFor(() => expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(), ); expect(screen.queryByLabelText("agent name")).toBeNull(); // View → Git — commit message input visible. openPanel("Git"); await waitFor(() => expect(screen.getByLabelText("commit message")).toBeTruthy(), ); // File → Projects… again — form accessible once more. openPanel("Projects"); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); }); it("edits the shared project context stored under .ideai", async () => { const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); await project.updateProjectContext(created.id, "# Existing context"); renderView(project); await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); openPanel("Project context"); const editor = (await screen.findByLabelText( "project context", )) as HTMLTextAreaElement; expect(editor.value).toBe("# Existing context"); fireEvent.change(editor, { target: { value: "# Shared\n\nUse pnpm." } }); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(async () => { await expect(project.readProjectContext(created.id)).resolves.toBe( "# Shared\n\nUse pnpm.", ); }); }); it("Browse… button calls pickFolder and fills the root field with the returned path", async () => { const system = new MockSystemGateway(); renderView(new MockProjectGateway(), system); await waitForIdle(); // Root field starts empty. const rootInput = screen.getByLabelText("project root") as HTMLInputElement; expect(rootInput.value).toBe(""); // Click Browse… — the mock returns "/home/user/mock-project". fireEvent.click(screen.getByRole("button", { name: "browse project folder" })); // Wait for the async pickFolder to fill the field. await waitFor(() => { expect((screen.getByLabelText("project root") as HTMLInputElement).value).toBe( "/home/user/mock-project", ); }); }); it("opening an unknown project id surfaces a NOT_FOUND error", async () => { // Stub a stale list entry whose id the gateway won't resolve. const project = new MockProjectGateway(); project.listProjects = async () => [ { id: "ghost-id", name: "ghost", root: "/p/ghost", remote: { kind: "local" }, createdAt: 0, }, ]; renderView(project); await screen.findByText("/p/ghost"); fireEvent.click(screen.getByRole("button", { name: "Open" })); const alert = await screen.findByRole("alert"); expect(alert.textContent).toMatch(/not found/i); expect(screen.getByText("No open tabs.")).toBeTruthy(); }); });