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.
235 lines
7.8 KiB
TypeScript
235 lines
7.8 KiB
TypeScript
/**
|
|
* LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers
|
|
* (ARCHITECTURE §21).
|
|
*
|
|
* Three layers:
|
|
* - the exported pure helpers `formatCountdown` / `formatResetTime` /
|
|
* `timeInputToEpochMs` (edge cases: 0, negative, sub-minute, multi-minute,
|
|
* midnight, seconds-stripped, malformed/empty time input);
|
|
* - the rendered badge across its states (limité jusqu'à HH:MM / "limité"
|
|
* without a time / the "heure inconnue" resume-time form for a suspected limit
|
|
* without a reset), plus the "Annuler la reprise" button wiring when a resume
|
|
* is armed;
|
|
* - the human-net form (LS8): submitting a time arms a resume via onSetResumeAt.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
|
|
import {
|
|
AgentLimitBadge,
|
|
formatCountdown,
|
|
formatResetTime,
|
|
timeInputToEpochMs,
|
|
} from "./AgentLimitBadge";
|
|
|
|
describe("formatCountdown", () => {
|
|
it("renders 0s for exactly zero", () => {
|
|
expect(formatCountdown(0)).toBe("0s");
|
|
});
|
|
|
|
it("clamps a negative (past) deadline to 0s", () => {
|
|
expect(formatCountdown(-5_000)).toBe("0s");
|
|
});
|
|
|
|
it("renders sub-minute durations as just seconds", () => {
|
|
expect(formatCountdown(5_000)).toBe("5s");
|
|
expect(formatCountdown(59_000)).toBe("59s");
|
|
});
|
|
|
|
it("rounds partial seconds up (ceil)", () => {
|
|
expect(formatCountdown(4_200)).toBe("5s");
|
|
expect(formatCountdown(1)).toBe("1s");
|
|
});
|
|
|
|
it("renders minutes + seconds past a minute", () => {
|
|
expect(formatCountdown(60_000)).toBe("1m 0s");
|
|
expect(formatCountdown(90_000)).toBe("1m 30s");
|
|
expect(formatCountdown(125_000)).toBe("2m 5s");
|
|
});
|
|
});
|
|
|
|
describe("formatResetTime", () => {
|
|
it("produces an HH:MM wall-clock label (no seconds component)", () => {
|
|
// Same minute, +30s apart → identical label (seconds are not shown).
|
|
const base = new Date(2026, 0, 15, 9, 5, 0).getTime();
|
|
const plus30s = new Date(2026, 0, 15, 9, 5, 30).getTime();
|
|
expect(formatResetTime(base)).toBe(formatResetTime(plus30s));
|
|
// Matches the documented HH:MM contract (delegates to toLocaleTimeString).
|
|
expect(formatResetTime(base)).toBe(
|
|
new Date(base).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("renders midnight as a stable two-digit label", () => {
|
|
const midnight = new Date(2026, 0, 15, 0, 0, 0).getTime();
|
|
const label = formatResetTime(midnight);
|
|
expect(label).toBe(
|
|
new Date(midnight).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
);
|
|
// Minutes are "00" at midnight regardless of 12h/24h locale.
|
|
expect(label).toContain("00");
|
|
});
|
|
});
|
|
|
|
describe("timeInputToEpochMs", () => {
|
|
it("maps HH:MM to the same calendar day as `now`", () => {
|
|
const now = new Date(2026, 0, 15, 9, 0, 0).getTime();
|
|
const ms = timeInputToEpochMs("14:30", now);
|
|
expect(ms).not.toBeNull();
|
|
const d = new Date(ms!);
|
|
expect(d.getFullYear()).toBe(2026);
|
|
expect(d.getMonth()).toBe(0);
|
|
expect(d.getDate()).toBe(15);
|
|
expect(d.getHours()).toBe(14);
|
|
expect(d.getMinutes()).toBe(30);
|
|
expect(d.getSeconds()).toBe(0);
|
|
expect(d.getMilliseconds()).toBe(0);
|
|
});
|
|
|
|
it("returns a past instant unchanged (backend clamps to now)", () => {
|
|
const now = new Date(2026, 0, 15, 18, 0, 0).getTime();
|
|
const ms = timeInputToEpochMs("08:00", now);
|
|
expect(ms).not.toBeNull();
|
|
expect(ms!).toBeLessThan(now);
|
|
});
|
|
|
|
it("returns null for empty or malformed input", () => {
|
|
const now = Date.now();
|
|
expect(timeInputToEpochMs("", now)).toBeNull();
|
|
expect(timeInputToEpochMs("nope", now)).toBeNull();
|
|
expect(timeInputToEpochMs("25:00", now)).toBeNull();
|
|
expect(timeInputToEpochMs("12:60", now)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("AgentLimitBadge (render)", () => {
|
|
const noop = () => {};
|
|
|
|
it("shows 'limité jusqu'à HH:MM' when the reset time is known", () => {
|
|
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ limitedUntil: at }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={noop}
|
|
/>,
|
|
);
|
|
const expected = `limité jusqu'à ${formatResetTime(at)}`;
|
|
expect(screen.getByText(expected)).toBeTruthy();
|
|
// No countdown / cancel button without an armed resume.
|
|
expect(screen.queryByLabelText("annuler la reprise")).toBeNull();
|
|
});
|
|
|
|
it("shows plain 'limité' when no reset time is known", () => {
|
|
render(
|
|
<AgentLimitBadge state={{}} onCancelResume={noop} onSetResumeAt={noop} />,
|
|
);
|
|
expect(screen.getByText("limité")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => {
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ suspected: true }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={noop}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("limité")).toBeTruthy();
|
|
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
|
|
// The human-net form: a resume-time input + a "schedule resume" button.
|
|
expect(screen.getByLabelText("heure de reprise")).toBeTruthy();
|
|
expect(screen.getByLabelText("programmer la reprise")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT show the resume-time form when a suspected limit has a time", () => {
|
|
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ suspected: true, limitedUntil: at }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={noop}
|
|
/>,
|
|
);
|
|
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
|
|
expect(screen.queryByText(/heure inconnue/)).toBeNull();
|
|
expect(screen.queryByLabelText("heure de reprise")).toBeNull();
|
|
});
|
|
|
|
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
|
|
const onSetResumeAt = vi.fn();
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ suspected: true }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={onSetResumeAt}
|
|
/>,
|
|
);
|
|
fireEvent.change(screen.getByLabelText("heure de reprise"), {
|
|
target: { value: "23:45" },
|
|
});
|
|
fireEvent.click(screen.getByLabelText("programmer la reprise"));
|
|
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
|
|
const ms = onSetResumeAt.mock.calls[0][0] as number;
|
|
const d = new Date(ms);
|
|
expect(d.getHours()).toBe(23);
|
|
expect(d.getMinutes()).toBe(45);
|
|
});
|
|
|
|
it("does not arm a resume when the time input is empty (button disabled)", () => {
|
|
const onSetResumeAt = vi.fn();
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ suspected: true }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={onSetResumeAt}
|
|
/>,
|
|
);
|
|
expect(
|
|
(screen.getByLabelText("programmer la reprise") as HTMLButtonElement).disabled,
|
|
).toBe(true);
|
|
expect(onSetResumeAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders the countdown + calls onCancelResume when a resume is armed", () => {
|
|
const onCancelResume = vi.fn();
|
|
const fireAt = Date.now() + 90_000;
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ limitedUntil: Date.now(), resumeFireAt: fireAt }}
|
|
onCancelResume={onCancelResume}
|
|
onSetResumeAt={noop}
|
|
/>,
|
|
);
|
|
// Countdown label present (≈ "reprise dans 1m 30s").
|
|
expect(screen.getByLabelText("compte à rebours de reprise").textContent).toMatch(
|
|
/reprise dans/,
|
|
);
|
|
|
|
const btn = screen.getByLabelText("annuler la reprise");
|
|
fireEvent.click(btn);
|
|
expect(onCancelResume).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("disables the cancel button while busy", () => {
|
|
render(
|
|
<AgentLimitBadge
|
|
state={{ resumeFireAt: Date.now() + 10_000 }}
|
|
onCancelResume={noop}
|
|
onSetResumeAt={noop}
|
|
busy
|
|
/>,
|
|
);
|
|
expect(
|
|
(screen.getByLabelText("annuler la reprise") as HTMLButtonElement).disabled,
|
|
).toBe(true);
|
|
});
|
|
});
|