Files
IdeaSDK/frontend/src/features/embedder/embedder.test.tsx
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00

238 lines
8.8 KiB
TypeScript

/**
* L14 / lot C2 — embedder settings feature wired to the stateful
* `MockEmbedderGateway` via the real `DIProvider` (same harness as
* `memory.test.tsx`).
*
* Covers:
* - the strategy list renders on an equal footing (None, ONNX, Ollama, API,
* Custom) with the "Recommended" badge on ONNX
* - conditional fields per strategy (ONNX model picker + auto dimension;
* localServer endpoint; api endpoint + apiKeyEnv label; none → no fields)
* - a strategy whose build flag is false is shown disabled with
* "not available in this build" (None stays available)
* - Save calls the gateway with the right profile
* - "Back to None" deletes the configured profiles
*
* Plus the active-tier transparency line in MemoryPanel (Naïve vs Vector).
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockEmbedderGateway, MockMemoryGateway } from "@/adapters/mock";
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "./EmbedderSettings";
function renderEmbedder(
engines?: Partial<EmbedderEngines>,
seed: EmbedderProfile[] = [],
) {
const embedder = new MockEmbedderGateway(engines, seed);
const gateways = { embedder } as unknown as Gateways;
return {
embedder,
...render(
<DIProvider gateways={gateways}>
<EmbedderSettings />
</DIProvider>,
),
};
}
async function waitForEmbedderIdle() {
await waitFor(() => {
expect(
(screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
}
describe("EmbedderSettings (with MockEmbedderGateway)", () => {
it("renders the strategy list on an equal footing with a Recommended badge", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// Every strategy is offered.
expect(screen.getByTestId("embedder-strategy-none")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-localOnnx")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-localServer")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-api")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy();
// ONNX carries the "Recommended" badge.
expect(screen.getByText("Recommended")).toBeTruthy();
// The change-takes-effect notice is visible.
expect(screen.getByTestId("embedder-restart-note").textContent).toMatch(
/next app start/i,
);
});
it("pre-selects ONNX with the recommended model and auto-fills its dimension (384)", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// ONNX is the default cursor → its model picker is shown.
const modelSelect = (await screen.findByLabelText(
"onnx model",
)) as HTMLSelectElement;
expect(modelSelect.value).toBe("e5-small");
// Dimension auto-filled from the model.
expect((screen.getByLabelText("dimension") as HTMLInputElement).value).toBe(
"384",
);
});
it("shows endpoint for localServer and endpoint + apiKeyEnv (named, not the key) for api", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// localServer → endpoint, no apiKeyEnv.
fireEvent.click(
screen.getByRole("radio", { name: "Ollama (local server)" }),
);
await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy());
expect(screen.queryByLabelText("api key env var")).toBeNull();
// api → endpoint + apiKeyEnv with an explicit "env var" label.
fireEvent.click(screen.getByRole("radio", { name: "API" }));
await waitFor(() =>
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
);
expect(screen.getByText(/environment variable/i)).toBeTruthy();
expect(screen.getByText(/never the key itself/i)).toBeTruthy();
});
it("renders no conditional fields for None", async () => {
renderEmbedder();
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" }));
await waitFor(() =>
expect(screen.queryByLabelText("dimension")).toBeNull(),
);
expect(screen.queryByLabelText("onnx model")).toBeNull();
// No Save button in None mode (only "Back to None").
expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull();
});
it("disables strategies whose build flag is false with a 'not available' note", async () => {
renderEmbedder({ vectorOnnxEnabled: false, vectorHttpEnabled: false });
await waitForEmbedderIdle();
const onnxRadio = screen.getByRole("radio", {
name: "Local ONNX",
}) as HTMLInputElement;
const ollamaRadio = screen.getByRole("radio", {
name: "Ollama (local server)",
}) as HTMLInputElement;
const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement;
const noneRadio = screen.getByRole("radio", {
name: "None (naïve recall)",
}) as HTMLInputElement;
expect(onnxRadio.disabled).toBe(true);
expect(ollamaRadio.disabled).toBe(true);
expect(apiRadio.disabled).toBe(true);
// None is always available.
expect(noneRadio.disabled).toBe(false);
// The honesty note appears.
expect(
screen.getAllByText("not available in this build").length,
).toBeGreaterThan(0);
});
it("Save sends the recommended ONNX profile (e5-small, dim 384)", async () => {
const { embedder } = renderEmbedder();
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
const saved = spy.mock.calls[0][0];
expect(saved.strategy).toBe("localOnnx");
expect(saved.model).toBe("e5-small");
expect(saved.dimension).toBe(384);
expect(saved.id.length).toBeGreaterThan(0);
expect(saved.name.length).toBeGreaterThan(0);
// ONNX never carries an endpoint/apiKeyEnv.
expect(saved.endpoint).toBeUndefined();
expect(saved.apiKeyEnv).toBeUndefined();
});
it("Save for api sends endpoint + apiKeyEnv (the env var NAME)", async () => {
const { embedder } = renderEmbedder();
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("radio", { name: "API" }));
await waitFor(() =>
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText("api key env var"), {
target: { value: "MY_KEY_VAR" },
});
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
await waitFor(() => expect(spy).toHaveBeenCalled());
const saved = spy.mock.calls.at(-1)![0];
expect(saved.strategy).toBe("api");
expect(saved.apiKeyEnv).toBe("MY_KEY_VAR");
expect(saved.endpoint).toBeTruthy();
});
it("'Back to None' deletes the configured profiles", async () => {
const seed: EmbedderProfile[] = [
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
];
const { embedder } = renderEmbedder(undefined, seed);
const spy = vi.spyOn(embedder, "deleteEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("button", { name: "back to none" }));
await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local"));
expect(await embedder.listEmbedderProfiles()).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// MemoryPanel — active recall tier transparency
// ---------------------------------------------------------------------------
describe("MemoryPanel active tier (transparency)", () => {
function renderPanel(embedder: MockEmbedderGateway) {
const gateways = {
memory: new MockMemoryGateway(),
embedder,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<MemoryPanel projectId="p" />
</DIProvider>,
);
}
it("shows 'Naïve (None)' when no embedder is configured", async () => {
renderPanel(new MockEmbedderGateway());
const tier = await screen.findByTestId("memory-tier");
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/));
});
it("shows 'Vector — <strategy> <model>' when an embedder is configured", async () => {
const embedder = new MockEmbedderGateway(undefined, [
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
]);
renderPanel(embedder);
const tier = await screen.findByTestId("memory-tier");
await waitFor(() => expect(tier.textContent).toMatch(/Vector — localOnnx e5-small/));
});
});