343 lines
10 KiB
TypeScript
343 lines
10 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: { projectId?: string | null; workState?: MockWorkStateGateway } = {},
|
|
) {
|
|
const system = new MockSystemGateway();
|
|
const workState = opts.workState;
|
|
const gateways = { system, workState } as unknown as Gateways;
|
|
const result = render(
|
|
<DIProvider gateways={gateways}>
|
|
<AnnouncementsProvider projectId={opts.projectId ?? "p1"}>
|
|
{node}
|
|
</AnnouncementsProvider>
|
|
</DIProvider>,
|
|
);
|
|
return { system, ...result };
|
|
}
|
|
|
|
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("ignores streamed announcements from another project", async () => {
|
|
const { system } = setup(
|
|
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
|
);
|
|
await flush();
|
|
|
|
act(() => {
|
|
system.emit(busy("agent-T", true));
|
|
system.emit(
|
|
announcement({
|
|
projectId: "p2",
|
|
target: "agent-T",
|
|
text: "other-project",
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(overlay()).not.toBeNull();
|
|
expect(screen.queryByText("other-project")).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("shows a stable busy fallback when no live announcement text exists", async () => {
|
|
const { system } = setup(
|
|
<TargetAnnouncementsOverlay
|
|
projectId="p1"
|
|
agentId="agent-T"
|
|
fallbackText="Travaille sur une demande de Main."
|
|
/>,
|
|
);
|
|
await flush();
|
|
|
|
act(() => system.emit(busy("agent-T", true)));
|
|
|
|
expect(screen.getByText("Travaille sur une demande de Main.")).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("resets and rehydrates when switching project", async () => {
|
|
const workState = new MockWorkStateGateway();
|
|
workState._setProjectWorkState("p1", {
|
|
agents: [
|
|
{
|
|
agentId: "agent-T",
|
|
name: "Target",
|
|
profileId: "prof",
|
|
busy: { state: "idle" },
|
|
tickets: [],
|
|
},
|
|
],
|
|
conversations: [],
|
|
});
|
|
workState._setProjectWorkState("p2", {
|
|
agents: [
|
|
{
|
|
agentId: "agent-T",
|
|
name: "Target",
|
|
profileId: "prof",
|
|
busy: { state: "busy", ticket: "t-p2", sinceMs: 456 },
|
|
tickets: [],
|
|
},
|
|
],
|
|
conversations: [],
|
|
});
|
|
|
|
const system = new MockSystemGateway();
|
|
const gateways = { system, workState } as unknown as Gateways;
|
|
const { rerender } = render(
|
|
<DIProvider gateways={gateways}>
|
|
<AnnouncementsProvider projectId="p1">
|
|
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />
|
|
</AnnouncementsProvider>
|
|
</DIProvider>,
|
|
);
|
|
await flush();
|
|
|
|
act(() => {
|
|
system.emit(busy("agent-T", true));
|
|
system.emit(announcement({ projectId: "p1", text: "old-p1" }));
|
|
});
|
|
expect(screen.getByText("old-p1")).not.toBeNull();
|
|
|
|
rerender(
|
|
<DIProvider gateways={gateways}>
|
|
<AnnouncementsProvider projectId="p2">
|
|
<TargetAnnouncementsOverlay projectId="p2" agentId="agent-T" />
|
|
</AnnouncementsProvider>
|
|
</DIProvider>,
|
|
);
|
|
await flush();
|
|
|
|
expect(overlay()).not.toBeNull();
|
|
expect(screen.queryByText("old-p1")).toBeNull();
|
|
expect(screen.getByText("Travaille sur une demande en cours.")).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 a requester-side fallback while waiting without live text", async () => {
|
|
setup(
|
|
<AnnouncementsPreview
|
|
requester="agent-A"
|
|
fallbackActive
|
|
fallbackText="Délégation en cours."
|
|
/>,
|
|
);
|
|
await flush();
|
|
|
|
expect(screen.getByTestId("announcements-preview")).not.toBeNull();
|
|
expect(screen.getByText("Délégation en cours.")).not.toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|