Files
IdeaSDK/frontend/src/features/announcements/AnnouncementsProvider.test.tsx
Blomios 50b2adfde3 feat(inter-agent): surface frontend des annonces live sur les cellules (F1-F3)
Affiche les annonces d'agent en direct au-dessus des cellules, sur la base
develop :

- announcements: nouveau feature module — store réactif, provider
  d'abonnement aux événements, overlay par cible et aperçu, avec tests
  (announcementsStore + provider).
- App.tsx: montage du provider dans l'arbre applicatif.
- domain/index.ts: types partagés de l'événement d'annonce côté front.
- layout: composition de l'overlay dans LayoutGrid + règle d'exclusion
  couverte par overlayExclusion.test.ts.
- AgentsPanel: intègre l'aperçu des annonces dans la surface existante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:50:13 +02:00

233 lines
7.2 KiB
TypeScript

/**
* F2/F3 integration (ticket #4, develop base — busy is the sole F3 authority):
*
* - {@link TargetAnnouncementsOverlay} (F3) is driven by the target's **busy**
* state, NOT by the presence of announcements. It mounts on
* `agentBusyChanged { busy:true }` (or a read-model hydration snapshot) and
* retracts on `busy:false`, **including when no completion event is ever
* emitted** — the interruption/crash/rate-limit case that used to stick the
* overlay. There is no `agentTurnEvent` on this base.
* - {@link AnnouncementsPreview} (F2) shows only the caller-requester's
* announcements on a shared target thread (`requester == self`).
*/
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { AnnouncementsProvider } from "./AnnouncementsProvider";
import { AnnouncementsPreview } from "./AnnouncementsPreview";
import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
function setup(
node: React.ReactNode,
opts: { workState?: MockWorkStateGateway } = {},
) {
const system = new MockSystemGateway();
const workState = opts.workState;
const gateways = { system, workState } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<AnnouncementsProvider>{node}</AnnouncementsProvider>
</DIProvider>,
);
return { system };
}
type AnnouncementEvent = Extract<DomainEvent, { type: "agentAnnouncement" }>;
function announcement(over: Partial<AnnouncementEvent> = {}): AnnouncementEvent {
return {
type: "agentAnnouncement",
projectId: "p1",
requester: "agent-A",
target: "agent-T",
ticketId: "t1",
text: "je réfléchis…",
atMs: 1,
...over,
};
}
function busy(agentId: string, isBusy: boolean): DomainEvent {
return { type: "agentBusyChanged", agentId, busy: isBusy };
}
// The provider subscribes asynchronously (onDomainEvent returns a Promise) and
// hydration awaits the read-model; flush microtasks before/after emitting.
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
const overlay = () => screen.queryByTestId("target-announcements-overlay");
describe("F3 — overlay lifecycle is the target's busy state", () => {
it("mounts on busy:true and shows the streamed announcement as content", async () => {
const { system } = setup(
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
);
await flush();
expect(overlay()).toBeNull();
act(() => {
system.emit(busy("agent-T", true));
system.emit(announcement({ target: "agent-T", text: "en cours" }));
});
expect(overlay()).not.toBeNull();
expect(screen.getByText("en cours")).not.toBeNull();
});
it("mounts on busy:true even before any announcement (banner only)", async () => {
const { system } = setup(
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
);
await flush();
act(() => system.emit(busy("agent-T", true)));
expect(overlay()).not.toBeNull();
});
it("RETRACTS on busy:false even when NO completion event is emitted", async () => {
// The regression the arbitrage targets: a turn that ends without any
// completion signal (interruption / error / crash) must still bring the
// overlay down. On develop the only signal is busy — and it suffices.
const { system } = setup(
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
);
await flush();
act(() => {
system.emit(busy("agent-T", true));
system.emit(announcement({ target: "agent-T", text: "orphan" }));
});
expect(overlay()).not.toBeNull();
act(() => system.emit(busy("agent-T", false)));
expect(overlay()).toBeNull();
});
it("clears stale content on busy:false so a next turn starts fresh", async () => {
const { system } = setup(
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
);
await flush();
act(() => {
system.emit(busy("agent-T", true));
system.emit(announcement({ target: "agent-T", text: "old" }));
system.emit(busy("agent-T", false));
system.emit(busy("agent-T", true));
});
// New turn: the previous "old" réflexion must not be shown.
expect(overlay()).not.toBeNull();
expect(screen.queryByText("old")).toBeNull();
});
it("hydrates busy from the reconciled read-model at mount (no live event)", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("p1", {
agents: [
{
agentId: "agent-T",
name: "Target",
profileId: "prof",
busy: { state: "busy", ticket: "t9", sinceMs: 123 },
tickets: [],
},
],
conversations: [],
});
setup(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
workState,
});
await flush();
expect(overlay()).not.toBeNull();
});
it("stays down when the read-model reports the agent idle", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("p1", {
agents: [
{
agentId: "agent-T",
name: "Target",
profileId: "prof",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
setup(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
workState,
});
await flush();
expect(overlay()).toBeNull();
});
it("lets a live busy:false win over a stale busy hydration snapshot", async () => {
// Hydration seeds only when the agent is unknown; a later live idle event must
// still retract (live authority wins).
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("p1", {
agents: [
{
agentId: "agent-T",
name: "Target",
profileId: "prof",
busy: { state: "busy", ticket: "t9", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
const { system } = setup(
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
{ workState },
);
await flush();
expect(overlay()).not.toBeNull();
act(() => system.emit(busy("agent-T", false)));
expect(overlay()).toBeNull();
});
});
describe("F2 — AnnouncementsPreview (requester filter)", () => {
it("shows the requester's own announcements and hides another's", async () => {
const { system } = setup(<AnnouncementsPreview requester="agent-A" />);
await flush();
act(() => {
system.emit(announcement({ requester: "agent-A", text: "for-A" }));
system.emit(
announcement({ requester: "agent-B", ticketId: "t2", text: "for-B" }),
);
});
expect(screen.getByText("for-A")).not.toBeNull();
expect(screen.queryByText("for-B")).toBeNull();
});
it("renders nothing when the requester has no announcements", async () => {
setup(<AnnouncementsPreview requester="agent-A" />);
await flush();
expect(screen.queryByTestId("announcements-preview")).toBeNull();
});
});