Files
IdeA/frontend/src/features/agents/useAgentsLimits.test.tsx
Blomios 4fad0423e7 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>
2026-06-17 08:06:56 +02:00

236 lines
8.3 KiB
TypeScript

/**
* LS7-front — session-limit state in {@link useAgents} (ARCHITECTURE §21).
*
* Drives the hook behind the real {@link DIProvider} with an in-memory
* {@link MockSystemGateway} (to emit the limit domain events) and a
* {@link MockInputGateway} (to record `cancelResume`). Verifies that each of the
* five limit events folds correctly into `limitByAgent`, plus the optimistic
* `cancelResume` action and the backend verdict it returns.
*
* Cases:
* - `agentRateLimited` → `{ limitedUntil, suspected: false }`
* - `agentResumeScheduled` → arms `resumeFireAt`
* - `agentResumeCancelled` → drops `resumeFireAt`, stays limité
* - `agentResumed` → clears the entry entirely
* - `agentRateLimitSuspected` (with & without `resetsAtMs`) → `suspected: true`
* - realistic sequence rateLimited → scheduled → cancelResume (action)
* - `cancelResume` action: optimistic drop + both backend verdicts
*/
import { describe, it, expect } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useAgents } from "./useAgents";
const PROJECT_ID = "proj-limits-001";
const AGENT = "agent-A";
function setup() {
const system = new MockSystemGateway();
const input = new MockInputGateway();
// useAgents calls agent.listAgents / profile.listProfiles on mount; provide
// minimal stubs so the mount effect resolves without a backend.
const agent = {
listAgents: async () => [],
listLiveAgents: async () => [],
};
const profile = { listProfiles: async () => [] };
const gateways = { system, input, agent, profile } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
return { system, input, view };
}
/** Emits an event and flushes the async subscription/microtasks. */
async function emit(system: MockSystemGateway, event: DomainEvent) {
await act(async () => {
system.emit(event);
await Promise.resolve();
});
}
describe("useAgents — session-limit state (§21)", () => {
it("agentRateLimited with a reset time → { limitedUntil, suspected:false }", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
expect(view.result.current.limitByAgent[AGENT]).toEqual({
limitedUntil: 1_800_000_000_000,
resumeFireAt: undefined,
suspected: false,
});
});
it("agentResumeScheduled arms resumeFireAt on top of the limited state", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
limitedUntil: 1_800_000_000_000,
resumeFireAt: 1_800_000_300_000,
suspected: false,
});
});
it("agentResumeCancelled drops resumeFireAt but keeps the agent limité", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
const state = view.result.current.limitByAgent[AGENT];
expect(state).toBeDefined();
expect(state.resumeFireAt).toBeUndefined();
expect(state.limitedUntil).toBe(1_800_000_000_000);
});
it("agentResumeCancelled on an unknown agent is a no-op (no entry created)", async () => {
const { system, view } = setup();
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
});
it("agentResumed clears the limit entry entirely", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, { type: "agentResumed", agentId: AGENT });
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
expect(AGENT in view.result.current.limitByAgent).toBe(false);
});
it("agentRateLimitSuspected with a time → { limitedUntil, suspected:true }", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimitSuspected",
agentId: AGENT,
resetsAtMs: 1_800_000_500_000,
});
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
limitedUntil: 1_800_000_500_000,
suspected: true,
});
});
it("agentRateLimitSuspected WITHOUT a time → suspected:true, no limitedUntil", async () => {
const { system, view } = setup();
await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT });
const state = view.result.current.limitByAgent[AGENT];
expect(state.suspected).toBe(true);
expect(state.limitedUntil).toBeUndefined();
});
it("realistic sequence: rateLimited → scheduled → cancelResume (action)", async () => {
const { system, input, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBe(
1_800_000_300_000,
);
// The user clicks "Annuler la reprise": optimistic drop + port call.
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
// Optimistic: the countdown is gone immediately; the agent stays limité.
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBeUndefined();
expect(view.result.current.limitByAgent[AGENT].limitedUntil).toBe(
1_800_000_000_000,
);
// The action routed through the InputGateway and returned the verdict.
expect(input.cancelledResumes).toEqual([AGENT]);
expect(verdict).toBe(true);
});
it("cancelResume relays a `false` backend verdict (already fired / none armed)", async () => {
const { system, input, view } = setup();
input.cancelResumeResult = false;
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
expect(verdict).toBe(false);
expect(input.cancelledResumes).toEqual([AGENT]);
// Still optimistically dropped locally.
expect(view.result.current.limitByAgent[AGENT]?.resumeFireAt).toBeUndefined();
});
it("cancelResume with no armed resume still calls the port (no-op locally)", async () => {
const { input, view } = setup();
// No event emitted: the agent has no entry at all.
await waitFor(() => expect(view.result.current).toBeTruthy());
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
expect(verdict).toBe(true);
expect(input.cancelledResumes).toEqual([AGENT]);
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
});
it("folds limits for two agents independently", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: "agent-A",
resetsAtMs: 111,
});
await emit(system, {
type: "agentRateLimitSuspected",
agentId: "agent-B",
});
expect(view.result.current.limitByAgent["agent-A"]).toMatchObject({
limitedUntil: 111,
suspected: false,
});
expect(view.result.current.limitByAgent["agent-B"]).toMatchObject({
suspected: true,
});
expect(view.result.current.limitByAgent["agent-B"].limitedUntil).toBeUndefined();
});
});