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>
236 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|