feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

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.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -21,6 +21,7 @@ import type {
StopPolicy,
} from "@/domain";
import { Button, IconButton, Input, Panel, cn } from "@/shared";
import { useI18n } from "@/i18n";
import type { ModelServersViewModel } from "./useModelServers";
import {
deriveServedModelName,
@ -42,11 +43,15 @@ function Hint({ children }: { children: React.ReactNode }) {
return <small className="text-xs text-muted">{children}</small>;
}
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
{ value: "stopOnAppExit", label: "Stop on app exit" },
{ value: "keepAlive", label: "Keep alive" },
{ value: "stopWhenUnused", label: "Stop when unused" },
];
type Tr = (french: string, english: string) => string;
function stopPolicies(tr: Tr): { value: StopPolicy; label: string }[] {
return [
{ value: "stopOnAppExit", label: tr("Arrêter à la fermeture de l’application", "Stop on app exit") },
{ value: "keepAlive", label: tr("Garder actif", "Keep alive") },
{ value: "stopWhenUnused", label: tr("Arrêter si inutilisé", "Stop when unused") },
];
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
@ -64,13 +69,14 @@ function formatBytes(bytes: number): string {
function deleteArtifactConfirmation(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
tr: Tr,
): string {
const size =
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
artifact.sizeBytes == null ? "" : `${tr("Espace libéré", "Space freed")}: ${formatBytes(artifact.sizeBytes)}.`;
return [
"Supprimer le modèle téléchargé ?",
`Serveur : ${server.name}`,
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
tr("Supprimer le modèle téléchargé ?", "Delete downloaded model?"),
`${tr("Serveur", "Server")}: ${server.name}`,
tr("Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", "The local server will remain configured, but IdeA will have to download this model again on the next launch."),
size,
]
.filter(Boolean)
@ -83,6 +89,7 @@ export interface ModelServersPanelProps {
}
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
const { tr } = useI18n();
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
// no editor open (list-only).
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
@ -108,25 +115,25 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
) {
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
if (!window.confirm(deleteArtifactConfirmation(server, artifact, tr))) return;
await vm.deleteArtifact(server.id);
}
return (
<Panel
aria-label="local model servers"
aria-label={tr("Serveurs de modèles locaux", "Local model servers")}
title={
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-content">
Local model servers
{tr("Serveurs de modèles locaux", "Local model servers")}
</h3>
<Button
size="sm"
aria-label="add model server"
aria-label={tr("Ajouter un serveur de modèles", "Add model server")}
onClick={startAdd}
className="ml-auto"
>
Add server
{tr("Ajouter un serveur", "Add server")}
</Button>
</div>
}
@ -145,8 +152,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
{vm.servers.length === 0 && !draft && (
<p className="text-xs text-muted">
No local server declared yet. Add one to auto-manage a `llama-server`,
or keep using an external endpoint.
{tr("Aucun serveur local déclaré. Ajoutez-en un pour gérer automatiquement un `llama-server`, ou continuez à utiliser un point d’accès externe.", "No local server declared yet. Add one to auto-manage a `llama-server`, or keep using an external endpoint.")}
</p>
)}
@ -168,7 +174,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
{server.autoStart ? ` · ${tr("démarrage automatique", "auto-start")}` : ""}
</span>
</span>
<span className="ml-auto flex items-center gap-1">
@ -176,35 +182,35 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
<Button
size="sm"
variant="secondary"
aria-label={`download in progress ${server.name}`}
aria-label={tr(`Téléchargement en cours pour ${server.name}`, `Download in progress for ${server.name}`)}
disabled
>
Téléchargement en cours
{tr("Téléchargement en cours", "Downloading")}
</Button>
)}
{downloaded && (
<Button
size="sm"
variant="danger"
aria-label={`delete downloaded model ${server.name}`}
aria-label={tr(`Supprimer le modèle téléchargé de ${server.name}`, `Delete downloaded model for ${server.name}`)}
onClick={() => void confirmDeleteArtifact(server, downloaded)}
loading={deletingArtifact}
disabled={vm.busy && !deletingArtifact}
>
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
{deletingArtifact ? tr("Suppression…", "Deleting…") : tr("Supprimer le modèle téléchargé", "Delete downloaded model")}
</Button>
)}
<Button
size="sm"
aria-label={`edit ${server.name}`}
aria-label={tr(`Modifier ${server.name}`, `Edit ${server.name}`)}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
{tr("Modifier", "Edit")}
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
aria-label={tr(`Supprimer ${server.name}`, `Delete ${server.name}`)}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
@ -242,6 +248,7 @@ function SourceTabs({
kind: ModelSourceKind;
onSelect: (kind: ModelSourceKind) => void;
}) {
const { tr } = useI18n();
const tab = (value: ModelSourceKind, label: string) => {
const active = kind === value;
return (
@ -263,11 +270,11 @@ function SourceTabs({
return (
<div
role="group"
aria-label="source du modèle"
aria-label={tr("Source du modèle", "Model source")}
className="flex gap-1 rounded-md border border-border p-1"
>
{tab("huggingFace", "Télécharger depuis Hugging Face")}
{tab("localPath", "Fichier .gguf local")}
{tab("huggingFace", tr("Télécharger depuis Hugging Face", "Download from Hugging Face"))}
{tab("localPath", tr("Fichier .gguf local", "Local .gguf file"))}
</div>
);
}
@ -280,8 +287,9 @@ function CommandPreview({
preview: ModelServerCommandPreview | null;
autoStart: boolean;
}) {
const { tr } = useI18n();
const [copied, setCopied] = useState(false);
const title = autoStart ? "Commande générée" : "Commande à lancer manuellement";
const title = autoStart ? tr("Commande générée", "Generated command") : tr("Commande à lancer manuellement", "Command to run manually");
async function copy() {
if (!preview) return;
@ -296,7 +304,7 @@ function CommandPreview({
return (
<section
aria-label="command preview"
aria-label={tr("Aperçu de la commande", "Command preview")}
className="flex flex-col gap-1 rounded-md border border-border bg-raised p-2"
>
<div className="flex items-center gap-2">
@ -304,24 +312,24 @@ function CommandPreview({
<Button
size="sm"
variant="ghost"
aria-label="copier la commande"
aria-label={tr("Copier la commande", "Copy command")}
onClick={() => void copy()}
disabled={!preview}
className="ml-auto"
>
{copied ? "Copié" : "Copier"}
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
</Button>
</div>
{preview ? (
<code
aria-label="preview command line"
aria-label={tr("Ligne de commande prévisualisée", "Command line preview")}
className="whitespace-pre-wrap break-all font-mono text-xs text-content"
>
{preview.display}
</code>
) : (
<span className="text-xs text-muted">
Renseigne une source de modèle pour voir la commande.
{tr("Renseignez une source de modèle pour voir la commande.", "Enter a model source to see the command.")}
</span>
)}
</section>
@ -346,6 +354,7 @@ function ServerEditor({
onCancel: () => void;
onSubmit: (nextDraft?: LocalModelServerConfig) => void;
}) {
const { tr } = useI18n();
const errors: ModelServerErrors = validateModelServer(draft);
const valid = Object.keys(errors).length === 0;
const patch = (next: Partial<LocalModelServerConfig>) =>
@ -431,17 +440,17 @@ function ServerEditor({
return (
<fieldset
aria-label="model server editor"
aria-label={tr("Éditeur de serveur de modèles", "Model server editor")}
className="flex flex-col gap-3 rounded-md border border-primary/40 p-3"
>
<legend className="px-1 text-xs font-medium text-muted">
{draft.name.trim().length > 0 ? draft.name : "New server"}
{draft.name.trim().length > 0 ? draft.name : tr("Nouveau serveur", "New server")}
</legend>
<label className="flex flex-col gap-1">
<Caption>Nom</Caption>
<Caption>{tr("Nom", "Name")}</Caption>
<Input
aria-label="server name"
aria-label={tr("Nom du serveur", "Server name")}
value={draft.name}
invalid={Boolean(errors.name)}
onChange={(e) => patch({ name: e.target.value })}
@ -451,15 +460,15 @@ function ServerEditor({
{/* --- Source du modèle --------------------------------------------- */}
<div className="flex flex-col gap-1">
<Caption>Source du modèle</Caption>
<Caption>{tr("Source du modèle", "Model source")}</Caption>
<SourceTabs kind={sourceKind} onSelect={selectKind} />
</div>
{sourceKind === "huggingFace" ? (
<label className="flex flex-col gap-1">
<Caption>Modèle Hugging Face</Caption>
<Caption>{tr("Modèle Hugging Face", "Hugging Face model")}</Caption>
<Input
aria-label="hugging face model"
aria-label={tr("Modèle Hugging Face", "Hugging Face model")}
value={hfRepo}
placeholder="unsloth/Qwen3.5-9B-GGUF:Q4_K_M"
invalid={Boolean(errors.modelSource)}
@ -472,16 +481,16 @@ function ServerEditor({
);
}}
/>
<Hint>llama.cpp téléchargera/cache le fichier automatiquement.</Hint>
<Hint>{tr("llama.cpp téléchargera et mettra le fichier en cache automatiquement.", "llama.cpp will download and cache the file automatically.")}</Hint>
{errors.modelSource && (
<small className="text-xs text-danger">{errors.modelSource}</small>
)}
</label>
) : (
<label className="flex flex-col gap-1">
<Caption>Fichier .gguf</Caption>
<Caption>{tr("Fichier .gguf", ".gguf file")}</Caption>
<Input
aria-label="local gguf file"
aria-label={tr("Fichier .gguf local", "Local .gguf file")}
value={localPath}
placeholder="/home/me/models/qwen.gguf"
invalid={Boolean(errors.modelSource)}
@ -494,7 +503,7 @@ function ServerEditor({
);
}}
/>
<Hint>Chemin absolu vers un .gguf déjà présent.</Hint>
<Hint>{tr("Chemin absolu vers un fichier .gguf déjà présent.", "Absolute path to an existing .gguf file.")}</Hint>
{errors.modelSource && (
<small className="text-xs text-danger">{errors.modelSource}</small>
)}
@ -503,15 +512,15 @@ function ServerEditor({
{/* --- Nom du modèle dans IdeA -------------------------------------- */}
<label className="flex flex-col gap-1">
<Caption>Nom du modèle dans IdeA</Caption>
<Caption>{tr("Nom du modèle dans IdeA", "Model name in IdeA")}</Caption>
<Input
aria-label="server served model name"
aria-label={tr("Nom du modèle servi", "Served model name")}
value={draft.servedModelName}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.servedModelName)}
onChange={(e) => patch({ servedModelName: e.target.value })}
/>
<Hint>Nom envoyé aux clients OpenAI-compatible.</Hint>
<Hint>{tr("Nom envoyé aux clients compatibles OpenAI.", "Name sent to OpenAI-compatible clients.")}</Hint>
{errors.servedModelName && (
<small className="text-xs text-danger">{errors.servedModelName}</small>
)}
@ -520,9 +529,9 @@ function ServerEditor({
{/* --- Serveur ------------------------------------------------------ */}
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
<Caption>Commande serveur</Caption>
<Caption>{tr("Commande du serveur", "Server command")}</Caption>
<Input
aria-label="server binary path"
aria-label={tr("Chemin de l’exécutable du serveur", "Server binary path")}
value={draft.binaryPath ?? ""}
placeholder="llama-server"
onChange={(e) => {
@ -530,13 +539,13 @@ function ServerEditor({
patch({ binaryPath: v.length === 0 ? undefined : v });
}}
/>
<Hint>Laisse vide pour résoudre `llama-server` via le PATH.</Hint>
<Hint>{tr("Laissez vide pour trouver `llama-server` via le PATH.", "Leave empty to resolve `llama-server` through PATH.")}</Hint>
</label>
<label className="flex min-w-[6rem] flex-col gap-1">
<Caption>Port</Caption>
<Caption>{tr("Port", "Port")}</Caption>
<Input
aria-label="server port"
aria-label={tr("Port du serveur", "Server port")}
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
inputMode="numeric"
invalid={Boolean(errors.port)}
@ -547,9 +556,9 @@ function ServerEditor({
</div>
<label className="flex flex-col gap-1">
<Caption>Base URL (OpenAI-compatible)</Caption>
<Caption>{tr("URL de base (compatible OpenAI)", "Base URL (OpenAI-compatible)")}</Caption>
<Input
aria-label="server base url"
aria-label={tr("URL de base du serveur", "Server base URL")}
value={draft.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
@ -563,16 +572,16 @@ function ServerEditor({
<label className="flex items-start gap-2">
<input
type="checkbox"
aria-label="server auto start"
aria-label={tr("Démarrage automatique du serveur", "Server auto start")}
checked={draft.autoStart}
disabled={!draft.modelSource}
onChange={(e) => patch({ autoStart: e.target.checked })}
className="mt-1 accent-primary disabled:opacity-50"
/>
<span className="flex flex-col">
<Caption>Démarrer automatiquement</Caption>
<Caption>{tr("Démarrer automatiquement", "Start automatically")}</Caption>
{!draft.modelSource && (
<Hint>Renseigne d'abord une source de modèle pour l'activer.</Hint>
<Hint>{tr("Renseignez d’abord une source de modèle pour l’activer.", "Enter a model source first to enable it.")}</Hint>
)}
</span>
</label>
@ -580,16 +589,16 @@ function ServerEditor({
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
<details className="rounded-md border border-border p-2">
<summary className="cursor-pointer text-xs font-medium text-muted">
Réglages llama.cpp
{tr("Réglages llama.cpp", "llama.cpp settings")}
</summary>
<div className="mt-2 flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>
Couches GPU <span className="text-muted">(-ngl)</span>
{tr("Couches GPU", "GPU layers")} <span className="text-muted">(-ngl)</span>
</Caption>
<Input
aria-label="gpu layers"
aria-label={tr("Couches GPU", "GPU layers")}
value={draft.gpuLayers === undefined ? "" : String(draft.gpuLayers)}
inputMode="numeric"
placeholder="99"
@ -600,15 +609,15 @@ function ServerEditor({
});
}}
/>
<Hint>0 = CPU seul.</Hint>
<Hint>{tr("0 = processeur uniquement.", "0 = CPU only.")}</Hint>
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>
Taille de contexte <span className="text-muted">(-c)</span>
{tr("Taille du contexte", "Context size")} <span className="text-muted">(-c)</span>
</Caption>
<Input
aria-label="context size"
aria-label={tr("Taille du contexte", "Context size")}
value={
draft.contextSize === undefined ? "" : String(draft.contextSize)
}
@ -627,10 +636,10 @@ function ServerEditor({
<label className="flex flex-col gap-1">
<Caption>
Adresse d'écoute <span className="text-muted">(--host)</span>
{tr("Adresse d’écoute", "Listen address")} <span className="text-muted">(--host)</span>
</Caption>
<Input
aria-label="listen host"
aria-label={tr("Adresse d’écoute", "Listen address")}
value={draft.host}
placeholder="127.0.0.1"
invalid={Boolean(errors.host)}
@ -644,20 +653,20 @@ function ServerEditor({
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label="jinja template"
aria-label={tr("Modèle de discussion Jinja", "Jinja chat template")}
checked={draft.jinja}
onChange={(e) => patch({ jinja: e.target.checked })}
className="accent-primary"
/>
<Caption>
Template chat Jinja <span className="text-muted">(--jinja)</span>
{tr("Modèle de discussion Jinja", "Jinja chat template")} <span className="text-muted">(--jinja)</span>
</Caption>
</label>
<label className="flex flex-col gap-1">
<Caption>Arguments supplémentaires</Caption>
<Caption>{tr("Arguments supplémentaires", "Extra arguments")}</Caption>
<Input
aria-label="extra arguments"
aria-label={tr("Arguments supplémentaires", "Extra arguments")}
value={argsText}
placeholder="--flash-attn --parallel 2"
onChange={(e) => setArgsText(e.target.value)}
@ -666,16 +675,15 @@ function ServerEditor({
/>
{reservedHits.length > 0 && (
<small className="text-xs text-warning" role="status">
{reservedHits.join(", ")} : cette option est déjà pilotée par un
champ ci-dessus.
{reservedHits.join(", ")} : {tr("cette option est déjà pilotée par un champ ci-dessus.", "this option is already controlled by a field above.")}
</small>
)}
</label>
<label className="flex items-center gap-2">
<Caption>Stop policy</Caption>
<Caption>{tr("Politique d’arrêt", "Stop policy")}</Caption>
<select
aria-label="server stop policy"
aria-label={tr("Politique d’arrêt du serveur", "Server stop policy")}
value={draft.stopPolicy}
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
className={cn(
@ -683,7 +691,7 @@ function ServerEditor({
"border border-border outline-none focus:border-primary",
)}
>
{STOP_POLICIES.map((p) => (
{stopPolicies(tr).map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
@ -699,20 +707,20 @@ function ServerEditor({
<Button
variant="primary"
size="sm"
aria-label="save model server"
aria-label={tr("Enregistrer le serveur de modèles", "Save model server")}
onClick={() => onSubmit(draftWithCommittedArgs())}
disabled={busy || !valid}
>
Save server
{tr("Enregistrer le serveur", "Save server")}
</Button>
<Button
variant="ghost"
size="sm"
aria-label="cancel model server"
aria-label={tr("Annuler la modification du serveur", "Cancel model server")}
onClick={onCancel}
disabled={busy}
>
Cancel
{tr("Annuler", "Cancel")}
</Button>
</div>
</fieldset>

View File

@ -238,16 +238,16 @@ function renderPanelInsideSpaceTrap(modelServer = new MockModelServerGateway())
describe("ModelServersPanel wizard (F35 V2)", () => {
it("declares a Hugging Face server end-to-end with a V2 payload", async () => {
const { modelServer } = renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
fireEvent.change(screen.getByLabelText("server name"), {
fireEvent.change(screen.getByLabelText("Nom du serveur"), {
target: { value: "Local A" },
});
fireEvent.change(screen.getByLabelText("hugging face model"), {
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
});
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
const save = screen.getByLabelText("Enregistrer le serveur de modèles") as HTMLButtonElement;
await waitFor(() => expect(save.disabled).toBe(false));
fireEvent.click(save);
@ -264,37 +264,37 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
expect(saved.host).toBe("127.0.0.1");
expect(saved.jinja).toBe(false);
expect("modelPath" in saved).toBe(false);
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
});
it("toggles between the Hugging Face and local .gguf source fields", () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
// HF tab is the default.
expect(screen.getByLabelText("hugging face model")).toBeTruthy();
expect(screen.queryByLabelText("local gguf file")).toBeNull();
expect(screen.getByLabelText("Modèle Hugging Face")).toBeTruthy();
expect(screen.queryByLabelText("Fichier .gguf local")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /fichier \.gguf local/i }));
expect(screen.getByLabelText("local gguf file")).toBeTruthy();
expect(screen.queryByLabelText("hugging face model")).toBeNull();
expect(screen.getByLabelText("Fichier .gguf local")).toBeTruthy();
expect(screen.queryByLabelText("Modèle Hugging Face")).toBeNull();
});
it("prefills the served model name from the source when left blank", () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.change(screen.getByLabelText("hugging face model"), {
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
});
expect(
(screen.getByLabelText("server served model name") as HTMLInputElement).value,
(screen.getByLabelText("Nom du modèle servi") as HTMLInputElement).value,
).toBe("Qwen3.5-9B-GGUF");
});
it("mirrors a soft warning when a reserved flag is typed in the extra args", () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.change(screen.getByLabelText("extra arguments"), {
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
fireEvent.change(screen.getByLabelText("Arguments supplémentaires"), {
target: { value: "--host 0.0.0.0" },
});
expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy();
@ -302,22 +302,22 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
it("keeps extra-args spaces while typing and only parses them on save", async () => {
const { modelServer } = renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
fireEvent.change(screen.getByLabelText("server name"), {
fireEvent.change(screen.getByLabelText("Nom du serveur"), {
target: { value: "Spaced Args" },
});
fireEvent.change(screen.getByLabelText("hugging face model"), {
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
});
const args = screen.getByLabelText("extra arguments") as HTMLInputElement;
const args = screen.getByLabelText("Arguments supplémentaires") as HTMLInputElement;
fireEvent.change(args, {
target: { value: "--flash-attn --parallel 2 " },
});
expect(args.value).toBe("--flash-attn --parallel 2 ");
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
const save = screen.getByLabelText("Enregistrer le serveur de modèles") as HTMLButtonElement;
await waitFor(() => expect(save.disabled).toBe(false));
fireEvent.click(save);
@ -329,9 +329,9 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
it("does not let surrounding keyboard handlers swallow spaces in the extra args field", () => {
renderPanelInsideSpaceTrap();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
const args = screen.getByLabelText("extra arguments") as HTMLInputElement;
const args = screen.getByLabelText("Arguments supplémentaires") as HTMLInputElement;
const accepted = fireEvent.keyDown(args, {
key: " ",
code: "Space",
@ -343,25 +343,25 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
it("blocks auto-start until a model source is set", () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
const autoStart = screen.getByLabelText("server auto start") as HTMLInputElement;
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
const autoStart = screen.getByLabelText("Démarrage automatique du serveur") as HTMLInputElement;
expect(autoStart.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("hugging face model"), {
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
});
expect(
(screen.getByLabelText("server auto start") as HTMLInputElement).disabled,
(screen.getByLabelText("Démarrage automatique du serveur") as HTMLInputElement).disabled,
).toBe(false);
});
it("renders the backend-built command preview after a source is entered", async () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.change(screen.getByLabelText("hugging face model"), {
fireEvent.click(screen.getByLabelText("Ajouter un serveur de modèles"));
fireEvent.change(screen.getByLabelText("Modèle Hugging Face"), {
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
});
const line = (await screen.findByLabelText(
"preview command line",
"Ligne de commande prévisualisée",
)) as HTMLElement;
expect(line.textContent).toMatch(/-hf unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
expect(line.textContent).toMatch(/--port 8080/);
@ -373,13 +373,13 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
modelServer.markInUse(SERVER.id);
renderPanel(modelServer);
await screen.findByLabelText("edit Local A");
fireEvent.click(screen.getByLabelText("delete Local A"));
await screen.findByLabelText("Modifier Local A");
fireEvent.click(screen.getByLabelText("Supprimer Local A"));
expect((await screen.findByRole("alert")).textContent).toMatch(
/encore utilisé par un profil/i,
);
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
});
it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => {
@ -395,7 +395,7 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
fireEvent.click(await screen.findByLabelText("Supprimer le modèle téléchargé de Local A"));
await waitFor(() => {
expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/));
@ -408,8 +408,8 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
});
});
expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy();
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
expect(screen.getByLabelText("Modifier Local A")).toBeTruthy();
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
confirm.mockRestore();
});
@ -429,9 +429,9 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
});
renderPanel(modelServer);
await screen.findByLabelText("edit Local A");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull();
await screen.findByLabelText("Modifier Local A");
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
expect(screen.queryByLabelText("Supprimer le modèle téléchargé Local file")).toBeNull();
});
it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => {
@ -442,10 +442,10 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
});
renderPanel(modelServer);
const downloading = await screen.findByLabelText("download in progress Local A");
const downloading = await screen.findByLabelText("Téléchargement en cours pour Local A");
expect((downloading as HTMLButtonElement).disabled).toBe(true);
expect(downloading.textContent).toContain("Téléchargement en cours");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
expect(screen.queryByLabelText("Supprimer le modèle téléchargé de Local A")).toBeNull();
});
it("shows a short inline error when artifact deletion is blocked", async () => {
@ -461,12 +461,12 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
fireEvent.click(await screen.findByLabelText("Supprimer le modèle téléchargé de Local A"));
expect((await screen.findByRole("alert")).textContent).toMatch(
/téléchargement en cours ou agent actif/i,
);
expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy();
expect(screen.getByLabelText("Supprimer le modèle téléchargé de Local A")).toBeTruthy();
confirm.mockRestore();
});
@ -476,11 +476,11 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
await modelServer.saveModelServer(SERVER);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("edit Local A"));
fireEvent.change(screen.getByLabelText("server served model name"), {
fireEvent.click(await screen.findByLabelText("Modifier Local A"));
fireEvent.change(screen.getByLabelText("Nom du modèle servi"), {
target: { value: "qwen3-coder-7b" },
});
fireEvent.click(screen.getByLabelText("save model server"));
fireEvent.click(screen.getByLabelText("Enregistrer le serveur de modèles"));
await waitFor(async () => {
const saved = await modelServer.listModelServers();