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:
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user