feat: finalise multi-profil Codex/Claude avec catalogue de modèles

- Backend : clone_profile_from_seed généralisé (non OpenCode)
- Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé)
- Backend : commandes Tauri list_claude_models/list_codex_models
- Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete
- Frontend : ModelSelect searchable partagé + fallback saisie manuelle
- Frontend : assignation agent nom · modèle
- Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents
This commit is contained in:
2026-07-26 14:56:26 +02:00
parent c807a70fea
commit ea7ea71230
21 changed files with 1298 additions and 109 deletions

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockProfileGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { ProfileModelCatalogEntry } from "@/domain";
import { ProfilesSettings } from "./ProfilesSettings";
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
return {
profile,
...render(
<DIProvider gateways={{ profile } as unknown as Gateways}>
<ProfilesSettings />
</DIProvider>,
),
};
}
async function waitReady() {
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
}
async function createProfile() {
const before = screen.queryAllByRole("listitem").length;
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
}
describe("ProfilesSettings", () => {
it("creates multiple named Codex and Claude profiles with different models", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
await createProfile();
let rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Codex mini" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "gpt-5-mini" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
fireEvent.click(screen.getByRole("tab", { name: "Claude" }));
await waitReady();
await createProfile();
await createProfile();
rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Claude Opus" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "claude-opus-4-8" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2);
expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2);
expect(saved.map((p) => p.model)).toEqual(
expect.arrayContaining([
"gpt-5-codex",
"gpt-5-mini",
"claude-sonnet-5",
"claude-opus-4-8",
]),
);
});
});
it("duplicates from an existing profile with '<name> copy' and preserves the model", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
});
});
it("keeps manual model entry available when the catalogue fails", async () => {
class CatalogueDownProfileGateway extends MockProfileGateway {
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return Promise.reject(new Error("catalogue down"));
}
}
renderSettings(new CatalogueDownProfileGateway());
await waitReady();
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
await createProfile();
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
/modele du profil/,
) as HTMLInputElement;
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(model.value).toBe("future-codex-model");
});
});