feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)
Expose la surface produit des limites de session côté React/TS,
au-dessus du câblage backend (9df5923).
- domain/index.ts : 5 variantes ajoutées au union DomainEvent
(agentRateLimited / ResumeScheduled / ResumeCancelled / Resumed /
RateLimitSuspected).
- ports/index.ts : cancelResume(agentId) ajouté à InputGateway.
- adapters/input.ts : TauriInputGateway.cancelResume → invoke("cancel_resume").
- adapters/mock/index.ts : MockInputGateway.cancelResume
(cancelledResumes / cancelResumeResult).
- features/agents/useAgents.ts : état limitByAgent + action cancelResume.
- features/agents/AgentLimitBadge.tsx (nouveau) : badge + compte à rebours
+ bouton Annuler + helpers purs.
- features/agents/AgentsPanel.tsx : câblage du badge.
Tests : useAgentsLimits.test.tsx (13) + AgentLimitBadge.test.tsx (11),
suite agents 63 tests verts, tsc --noEmit propre.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
149
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
149
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* LS7-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.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
AgentLimitBadge,
|
||||
formatCountdown,
|
||||
formatResetTime,
|
||||
} 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("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} />,
|
||||
);
|
||||
const expected = `limité jusqu'à ${formatResetTime(at)}`;
|
||||
expect(screen.getByText(expected)).toBeTruthy();
|
||||
// No countdown / cancel button without an armed resume.
|
||||
expect(screen.queryByLabelText("cancel resume")).toBeNull();
|
||||
});
|
||||
|
||||
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} />,
|
||||
);
|
||||
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", () => {
|
||||
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ suspected: true, limitedUntil: at }}
|
||||
onCancelResume={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
|
||||
expect(screen.queryByText(/heure inconnue/)).toBeNull();
|
||||
});
|
||||
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
// Countdown label present (≈ "reprise dans 1m 30s").
|
||||
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
|
||||
/reprise dans/,
|
||||
);
|
||||
|
||||
const btn = screen.getByLabelText("cancel resume");
|
||||
fireEvent.click(btn);
|
||||
expect(onCancelResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the cancel button while busy", () => {
|
||||
render(
|
||||
<AgentLimitBadge
|
||||
state={{ resumeFireAt: Date.now() + 10_000 }}
|
||||
onCancelResume={noop}
|
||||
busy
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user