feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)

UI permettant à l'humain de renseigner l'heure de reprise quand le
niveau 2 détecte une limite sans heure exploitable.

- ports/index.ts : setResumeAt(agentId, resetsAtMs) sur InputGateway.
- adapters/input.ts : setResumeAt → invoke("set_resume_at").
- adapters/mock/index.ts : MockInputGateway.setResumeAt (resumeArmings[]).
- features/agents/useAgents.ts : action setResumeAt (sans mutation optimiste).
- features/agents/AgentLimitBadge.tsx : formulaire de saisie d'heure sur
  l'état suspected sans heure + helper pur timeInputToEpochMs (TODO LS7 retiré).
- features/agents/AgentsPanel.tsx : câblage onSetResumeAt.

Tests AgentLimitBadge.test.tsx mis à jour au nouveau contrat + couverture LS8 ;
typecheck propre, suite agents verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 08:44:54 +02:00
parent c480d2820a
commit 5d9dd32c29
7 changed files with 225 additions and 29 deletions

View File

@ -1,13 +1,16 @@
/**
* LS7-front — {@link AgentLimitBadge} presentation + its pure helpers
* LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers
* (ARCHITECTURE §21).
*
* Two layers:
* - the exported pure helpers `formatCountdown` / `formatResetTime` (edge cases:
* 0, negative, sub-minute, multi-minute, midnight, seconds-stripped);
* - the rendered badge across its three states (limité jusqu'à HH:MM /
* "limité" without a time / "heure inconnue" for suspected without a reset),
* plus the "Annuler la reprise" button wiring when a resume is armed.
* 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";
@ -17,6 +20,7 @@ import {
AgentLimitBadge,
formatCountdown,
formatResetTime,
timeInputToEpochMs,
} from "./AgentLimitBadge";
describe("formatCountdown", () => {
@ -74,13 +78,48 @@ describe("formatResetTime", () => {
});
});
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} />,
<AgentLimitBadge
state={{ limitedUntil: at }}
onCancelResume={noop}
onSetResumeAt={noop}
/>,
);
const expected = `limité jusqu'à ${formatResetTime(at)}`;
expect(screen.getByText(expected)).toBeTruthy();
@ -89,30 +128,74 @@ describe("AgentLimitBadge (render)", () => {
});
it("shows plain 'limité' when no reset time is known", () => {
render(<AgentLimitBadge state={{}} onCancelResume={noop} />);
expect(screen.getByText("limité")).toBeTruthy();
});
it("shows the 'heure inconnue' note for a suspected limit without a time", () => {
render(
<AgentLimitBadge state={{ suspected: true }} onCancelResume={noop} />,
<AgentLimitBadge state={{}} onCancelResume={noop} onSetResumeAt={noop} />,
);
expect(screen.getByText("limité")).toBeTruthy();
expect(
screen.getByText(/heure inconnue — reprise à préciser/),
).toBeTruthy();
});
it("does NOT show the 'heure inconnue' note when a suspected limit has a time", () => {
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("resume time")).toBeTruthy();
expect(screen.getByLabelText("schedule resume")).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("resume time")).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("resume time"), {
target: { value: "23:45" },
});
fireEvent.click(screen.getByLabelText("schedule resume"));
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("schedule resume") as HTMLButtonElement).disabled,
).toBe(true);
expect(onSetResumeAt).not.toHaveBeenCalled();
});
it("renders the countdown + calls onCancelResume when a resume is armed", () => {
@ -122,6 +205,7 @@ describe("AgentLimitBadge (render)", () => {
<AgentLimitBadge
state={{ limitedUntil: Date.now(), resumeFireAt: fireAt }}
onCancelResume={onCancelResume}
onSetResumeAt={noop}
/>,
);
// Countdown label present (≈ "reprise dans 1m 30s").
@ -139,6 +223,7 @@ describe("AgentLimitBadge (render)", () => {
<AgentLimitBadge
state={{ resumeFireAt: Date.now() + 10_000 }}
onCancelResume={noop}
onSetResumeAt={noop}
busy
/>,
);