/** * L14 — memory feature wired to the stateful `MockMemoryGateway` via the real * `DIProvider` (same harness as `skills.test.tsx`). * * Covers: * - index rendering (title + hook + type badge) on mount * - create a note (New → fill name/description/type/content → Save) → appears in * the list, and `createMemory` receives the right payload * - edit a note (slug read-only; description/type/content editable) → persisted * - delete a note → removed from the list * - `[[slug]]` link resolution (existing target listed; broken link ignored) * - basic error state (gateway rejects → alert message shown) * * Also includes `MockMemoryGateway` unit tests (CRUD + index derivation + * `resolveLinks` ignoring broken links). */ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MockMemoryGateway, MockEmbedderGateway, MockSystemGateway, } from "@/adapters/mock"; import type { GatewayError } from "@/domain"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { MemoryPanel } from "./MemoryPanel"; const PROJECT_ID = "proj-memory-test"; function renderMemoryPanel( memory?: MockMemoryGateway, system: MockSystemGateway = new MockSystemGateway(), ) { const mem = memory ?? new MockMemoryGateway(); const gateways = { memory: mem, embedder: new MockEmbedderGateway(), system, } as unknown as Gateways; return { memory: mem, system, ...render( , ), }; } async function waitForMemoryIdle() { await waitFor(() => { expect( ( screen.getByRole("button", { name: "créer une mémoire" }) as HTMLButtonElement ).disabled, ).toBe(false); }); } /** Opens the editor in create-mode, fills it, and saves. */ async function createMemory( name: string, description: string, content: string, type: "user" | "feedback" | "project" | "reference" = "project", ) { await waitForMemoryIdle(); fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" })); await waitFor(() => expect(screen.getByLabelText("nom de la mémoire")).toBeTruthy()); fireEvent.change(screen.getByLabelText("nom de la mémoire"), { target: { value: name }, }); fireEvent.change(screen.getByLabelText("description de la mémoire"), { target: { value: description }, }); fireEvent.change(screen.getByLabelText("type de mémoire"), { target: { value: type }, }); fireEvent.change(screen.getByLabelText("contenu de la mémoire"), { target: { value: content }, }); fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" })); } // --------------------------------------------------------------------------- // MemoryPanel feature tests // --------------------------------------------------------------------------- describe("MemoryPanel (with MockMemoryGateway)", () => { it("shows the empty state initially", async () => { renderMemoryPanel(); await waitForMemoryIdle(); expect(screen.getByText("Aucune note de mémoire.")).toBeTruthy(); }); it("renders the index (title + hook + type) on mount", async () => { const memory = new MockMemoryGateway(); await memory.createMemory({ projectId: PROJECT_ID, name: "Build AppImage", description: "use APPIMAGE_EXTRACT_AND_RUN=1", type: "reference", content: "# notes", }); renderMemoryPanel(memory); await screen.findByText("Build AppImage"); expect(screen.getByText("use APPIMAGE_EXTRACT_AND_RUN=1")).toBeTruthy(); // Type badge. expect(screen.getByText("reference")).toBeTruthy(); }); it("creating a note adds it to the list with the right payload", async () => { const { memory } = renderMemoryPanel(); const spy = vi.spyOn(memory, "createMemory"); await createMemory("My Note", "a one-line hook", "# body", "user"); await screen.findByText("My Note"); // Payload received by the gateway. expect(spy).toHaveBeenCalledWith({ projectId: PROJECT_ID, name: "My Note", description: "a one-line hook", type: "user", content: "# body", }); // Persisted in the gateway under the derived slug. const note = await memory.getMemory(PROJECT_ID, "my-note"); expect(note.name).toBe("My Note"); expect(note.description).toBe("a one-line hook"); expect(note.type).toBe("user"); expect(note.content).toBe("# body"); }); it("Save is disabled until description and content are filled", async () => { renderMemoryPanel(); await waitForMemoryIdle(); fireEvent.click(screen.getByRole("button", { name: "créer une mémoire" })); await waitFor(() => expect(screen.getByRole("button", { name: "Enregistrer la note" })).toBeTruthy(), ); const save = screen.getByRole("button", { name: "Enregistrer la note", }) as HTMLButtonElement; expect(save.disabled).toBe(true); // Filling everything enables Save. fireEvent.change(screen.getByLabelText("nom de la mémoire"), { target: { value: "X" }, }); fireEvent.change(screen.getByLabelText("description de la mémoire"), { target: { value: "h" }, }); fireEvent.change(screen.getByLabelText("contenu de la mémoire"), { target: { value: "c" }, }); expect( (screen.getByRole("button", { name: "Enregistrer la note" }) as HTMLButtonElement) .disabled, ).toBe(false); }); it("editing a note keeps the slug read-only and persists the changes", async () => { const { memory } = renderMemoryPanel(); await createMemory("Editable Note", "old hook", "old body", "project"); await screen.findByText("Editable Note"); fireEvent.click( screen.getByRole("button", { name: "modifier la mémoire Editable Note" }), ); // Slug shown read-only — there's no editable "memory name" input in edit-mode. await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy()); expect(screen.queryByLabelText("nom de la mémoire")).toBeNull(); // Description / type / content are editable. fireEvent.change(screen.getByLabelText("description de la mémoire"), { target: { value: "new hook" }, }); fireEvent.change(screen.getByLabelText("type de mémoire"), { target: { value: "feedback" }, }); fireEvent.change(screen.getByLabelText("contenu de la mémoire"), { target: { value: "new body" }, }); fireEvent.click(screen.getByRole("button", { name: "Enregistrer la note" })); await waitFor(async () => { const note = await memory.getMemory(PROJECT_ID, "editable-note"); expect(note.description).toBe("new hook"); expect(note.type).toBe("feedback"); expect(note.content).toBe("new body"); }); }); it("deleting a note removes it from the list", async () => { renderMemoryPanel(); await createMemory("To Delete", "hook", "body", "project"); await screen.findByText("To Delete"); fireEvent.click( screen.getByRole("button", { name: "supprimer la mémoire To Delete" }), ); await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull()); }); it("resolves [[slug]] links: existing target listed, broken link ignored", async () => { const memory = new MockMemoryGateway(); // Target note that the linker will reference. await memory.createMemory({ projectId: PROJECT_ID, name: "Target Note", description: "the target", type: "project", content: "# target", }); // Source note links to the existing target and to a non-existent slug. await memory.createMemory({ projectId: PROJECT_ID, name: "Source Note", description: "the source", type: "project", content: "See [[target-note]] and [[ghost-slug]].", }); renderMemoryPanel(memory); fireEvent.click( await screen.findByRole("button", { name: "modifier la mémoire Source Note" }), ); // The resolved link to the existing note appears; the broken one does not. await waitFor(() => expect(screen.getByText("target-note")).toBeTruthy()); expect(screen.queryByText("ghost-slug")).toBeNull(); }); it("surfaces an error message when the gateway rejects", async () => { const memory = new MockMemoryGateway(); const err: GatewayError = { code: "INTERNAL", message: "boom from gateway" }; vi.spyOn(memory, "readIndex").mockRejectedValue(err); renderMemoryPanel(memory); const alert = await screen.findByRole("alert"); expect(alert.textContent).toMatch(/boom from gateway/i); }); it("refreshes the index when a memorySaved domain event fires", async () => { const memory = new MockMemoryGateway(); const system = new MockSystemGateway(); const spy = vi.spyOn(memory, "readIndex"); renderMemoryPanel(memory, system); await waitForMemoryIdle(); spy.mockClear(); await memory.createMemory({ projectId: PROJECT_ID, name: "Harvested Note", description: "created by idea-memory", type: "project", content: "harvested body", }); system.emit({ type: "memorySaved", slug: "harvested-note" }); expect(await screen.findByText("Harvested Note")).toBeTruthy(); expect(spy).toHaveBeenCalled(); }); it("does not refresh the index for unrelated domain events", async () => { const memory = new MockMemoryGateway(); const system = new MockSystemGateway(); const spy = vi.spyOn(memory, "readIndex"); renderMemoryPanel(memory, system); await waitForMemoryIdle(); spy.mockClear(); system.emit({ type: "projectCreated", projectId: "other-project" }); await waitFor(() => expect(spy).not.toHaveBeenCalled()); }); }); // --------------------------------------------------------------------------- // MockMemoryGateway unit tests // --------------------------------------------------------------------------- describe("MockMemoryGateway (unit)", () => { it("CRUD: create → get → update → delete by immutable slug", async () => { const gw = new MockMemoryGateway(); await gw.createMemory({ projectId: "p", name: "Hello World", description: "d", type: "project", content: "c", }); const got = await gw.getMemory("p", "hello-world"); expect(got.name).toBe("Hello World"); await gw.updateMemory("p", "hello-world", "d2", "user", "c2"); const updated = await gw.getMemory("p", "hello-world"); expect(updated.description).toBe("d2"); expect(updated.type).toBe("user"); expect(updated.content).toBe("c2"); // Name (and therefore slug) is immutable. expect(updated.name).toBe("Hello World"); await gw.deleteMemory("p", "hello-world"); await expect(gw.getMemory("p", "hello-world")).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("getMemory / updateMemory / deleteMemory throw NOT_FOUND for unknown slug", async () => { const gw = new MockMemoryGateway(); await expect(gw.getMemory("p", "ghost")).rejects.toMatchObject({ code: "NOT_FOUND", }); await expect( gw.updateMemory("p", "ghost", "d", "project", "c"), ).rejects.toMatchObject({ code: "NOT_FOUND" }); await expect(gw.deleteMemory("p", "ghost")).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("readIndex derives the recall index from the notes", async () => { const gw = new MockMemoryGateway(); await gw.createMemory({ projectId: "p", name: "Indexed Note", description: "hooky", type: "reference", content: "x", }); const index = await gw.readIndex("p"); expect(index).toEqual([ { slug: "indexed-note", title: "Indexed Note", hook: "hooky", type: "reference" }, ]); }); it("notes are partitioned by project", async () => { const gw = new MockMemoryGateway(); await gw.createMemory({ projectId: "p1", name: "A", description: "d", type: "project", content: "c", }); expect(await gw.readIndex("p1")).toHaveLength(1); expect(await gw.readIndex("p2")).toHaveLength(0); }); it("resolveLinks keeps existing targets and ignores broken / self links", async () => { const gw = new MockMemoryGateway(); await gw.createMemory({ projectId: "p", name: "Alpha", description: "d", type: "project", content: "c", }); await gw.createMemory({ projectId: "p", name: "Beta", description: "d", type: "project", content: "Links [[alpha]], [[ghost]] and self [[beta]].", }); const links = await gw.resolveLinks("p", "beta"); expect(links).toEqual(["alpha"]); }); });