/** * 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( , ); 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( , ); expect(screen.getByText("limité")).toBeTruthy(); }); it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => { render( , ); 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( , ); 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( , ); 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( , ); 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", () => { const onCancelResume = vi.fn(); const fireAt = Date.now() + 90_000; render( , ); // 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( , ); expect( (screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled, ).toBe(true); }); });