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.
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
/**
|
|
* 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(
|
|
<DIProvider gateways={gateways}>
|
|
<MemoryPanel projectId={PROJECT_ID} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
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"]);
|
|
});
|
|
});
|