feat: synchronize audience presentation route
This commit is contained in:
@@ -2,10 +2,20 @@ import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||
import { PresentationFooter } from "./PresentationFooter.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const standaloneSyncController = (): PresentationSyncController => ({
|
||||
state: { kind: "standalone" },
|
||||
startSession: vi.fn(async () => {}),
|
||||
joinSession: vi.fn(async () => {}),
|
||||
retry: vi.fn(),
|
||||
leaveSession: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
});
|
||||
|
||||
describe("PresentationFooter", () => {
|
||||
it("combines scene progress and evidence provenance", () => {
|
||||
const evidence: EvidenceRecord = {
|
||||
@@ -30,6 +40,7 @@ describe("PresentationFooter", () => {
|
||||
}}
|
||||
evidence={[evidence]}
|
||||
demoRail={hiddenRail}
|
||||
syncController={standaloneSyncController()}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt
|
||||
inspectEvidence={vi.fn()}
|
||||
@@ -52,6 +63,7 @@ describe("PresentationFooter", () => {
|
||||
}}
|
||||
evidence={[]}
|
||||
demoRail={{ kind: "hidden" }}
|
||||
syncController={standaloneSyncController()}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt={false}
|
||||
inspectEvidence={vi.fn()}
|
||||
@@ -80,6 +92,7 @@ describe("PresentationFooter", () => {
|
||||
canRun: true,
|
||||
canRetry: true,
|
||||
}}
|
||||
syncController={standaloneSyncController()}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt={false}
|
||||
inspectEvidence={vi.fn()}
|
||||
@@ -89,4 +102,38 @@ describe("PresentationFooter", () => {
|
||||
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
||||
expect(screen.getByText("Live target ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps audience pairing in the non-demo utility area beside one demo rail", () => {
|
||||
render(
|
||||
<PresentationFooter
|
||||
location={{ kind: "main", sceneId: "agent-handoff", beatId: "request", focusPath: [] }}
|
||||
evidence={[]}
|
||||
demoRail={{
|
||||
kind: "action",
|
||||
mode: "live",
|
||||
label: "Run prepared workflow",
|
||||
status: {
|
||||
kind: "ready",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
label: "Live target ready",
|
||||
detail: "127.0.0.1:8765",
|
||||
},
|
||||
canRun: true,
|
||||
canRetry: true,
|
||||
}}
|
||||
syncController={standaloneSyncController()}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt={false}
|
||||
inspectEvidence={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
||||
const utility = footer.querySelector<HTMLElement>(".presentation-footer__utility");
|
||||
expect(utility).not.toBeNull();
|
||||
if (utility === null) throw new Error("presentation utility area is missing");
|
||||
expect(within(utility).getByRole("complementary", { name: "Presentation pairing" }))
|
||||
.toHaveAttribute("data-role", "audience");
|
||||
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
||||
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
import { PresentationPairingPanel } from "./sync/PresentationPairingPanel.js";
|
||||
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||
|
||||
type PresentationFooterProps = {
|
||||
readonly location: MainLocation;
|
||||
@@ -14,6 +16,7 @@ type PresentationFooterProps = {
|
||||
readonly retryHealth: () => void;
|
||||
readonly showEvidenceReceipt: boolean;
|
||||
readonly inspectEvidence: () => void;
|
||||
readonly syncController: PresentationSyncController;
|
||||
};
|
||||
|
||||
export const PresentationFooter = ({
|
||||
@@ -24,6 +27,7 @@ export const PresentationFooter = ({
|
||||
retryHealth,
|
||||
showEvidenceReceipt,
|
||||
inspectEvidence,
|
||||
syncController,
|
||||
}: PresentationFooterProps) => (
|
||||
<footer className="presentation-footer" aria-label="presentation footer">
|
||||
<SceneProgress location={location} />
|
||||
@@ -32,10 +36,13 @@ export const PresentationFooter = ({
|
||||
runPreparedWorkflow={runPreparedWorkflow}
|
||||
retryHealth={retryHealth}
|
||||
/>
|
||||
<EvidenceReceipt
|
||||
records={evidence}
|
||||
visible={showEvidenceReceipt}
|
||||
onInspect={inspectEvidence}
|
||||
/>
|
||||
<div className="presentation-footer__utility">
|
||||
<PresentationPairingPanel role="audience" controller={syncController} />
|
||||
<EvidenceReceipt
|
||||
records={evidence}
|
||||
visible={showEvidenceReceipt}
|
||||
onInspect={inspectEvidence}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,104 @@ const healthyResponse = {
|
||||
durationMs: 2,
|
||||
};
|
||||
|
||||
const syncGrant = {
|
||||
sessionId: "session-route",
|
||||
code: "ABC123",
|
||||
connectionToken: "token-route",
|
||||
websocketPath: "/api/presentation-sync/ws" as const,
|
||||
snapshot: { hash: "#scene/thesis/title", revision: 0 },
|
||||
};
|
||||
|
||||
class SyncTestSocket {
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 3;
|
||||
readonly sent: string[] = [];
|
||||
readonly url: string;
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
|
||||
|
||||
constructor(url: string, private readonly sockets: SyncTestSocket[]) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
this.sent.push(message);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = SyncTestSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
serverMessage(message: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(message) });
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "closed"): void {
|
||||
this.readyState = SyncTestSocket.CLOSED;
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
}
|
||||
|
||||
const installSyncTransport = (serverAvailable = true) => {
|
||||
const sockets: SyncTestSocket[] = [];
|
||||
const fetch = vi.fn<typeof globalThis.fetch>();
|
||||
if (serverAvailable) {
|
||||
fetch.mockResolvedValue(new Response(JSON.stringify(syncGrant), { status: 201 }));
|
||||
} else {
|
||||
fetch.mockRejectedValue(new Error("The pairing server is unavailable."));
|
||||
}
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
vi.stubGlobal(
|
||||
"WebSocket",
|
||||
class extends SyncTestSocket {
|
||||
constructor(url: string) {
|
||||
super(url, sockets);
|
||||
}
|
||||
},
|
||||
);
|
||||
return { fetch, sockets };
|
||||
};
|
||||
|
||||
const locationSnapshot = (hash: string, revision: number) => ({
|
||||
type: "location.snapshot",
|
||||
snapshot: { hash, revision },
|
||||
originatingMessageId: null,
|
||||
});
|
||||
|
||||
const presenceSnapshot = {
|
||||
type: "presence.snapshot",
|
||||
presence: { presenters: 1, audience: 1 },
|
||||
};
|
||||
|
||||
const publishedMessages = (socket: SyncTestSocket | undefined) =>
|
||||
socket?.sent
|
||||
.map((message) => JSON.parse(message) as { readonly type?: string; readonly hash?: string })
|
||||
.filter((message) => message.type === "location.publish") ?? [];
|
||||
|
||||
const connectAudience = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
sockets: SyncTestSocket[],
|
||||
initialHash = window.location.hash,
|
||||
): Promise<SyncTestSocket> => {
|
||||
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
|
||||
await user.click(screen.getByRole("button", { name: "Start session" }));
|
||||
await waitFor(() => expect(sockets).toHaveLength(1));
|
||||
const socket = sockets[0];
|
||||
if (socket === undefined) throw new Error("sync test socket was not created");
|
||||
await act(async () => {
|
||||
socket.open();
|
||||
socket.serverMessage(locationSnapshot(initialHash, 0));
|
||||
socket.serverMessage(presenceSnapshot);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("status", { name: "Connected" })).toBeInTheDocument());
|
||||
return socket;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
mockedCallOperation.mockResolvedValue(healthyResponse);
|
||||
});
|
||||
@@ -66,6 +164,7 @@ const graphNodeByLabel = (label: RegExp): HTMLElement => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
mockedCallOperation.mockReset();
|
||||
mockedCallOperation.mockResolvedValue(healthyResponse);
|
||||
window.sessionStorage.clear();
|
||||
@@ -73,6 +172,97 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("PresentationRoute", () => {
|
||||
it("renders the uniform audience pairing panel on /present", { timeout: 15000 }, async () => {
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
const panel = screen.getByRole("complementary", { name: "Presentation pairing" });
|
||||
expect(panel).toHaveAttribute("data-role", "audience");
|
||||
expect(within(panel).getByRole("button", { name: "Pair presentation" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("publishes one complete hash for local ArrowRight navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { sockets } = installSyncTransport();
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
const socket = await connectAudience(user, sockets);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" });
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
|
||||
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
|
||||
expect(publishedMessages(socket)[0]).toMatchObject({
|
||||
type: "location.publish",
|
||||
hash: "#scene/thesis/substrate",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes direct hashes and figure focus changes with their complete focus paths", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { sockets } = installSyncTransport();
|
||||
window.location.hash = "#scene/architecture/overview";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
const socket = await connectAudience(user, sockets);
|
||||
window.location.hash = "#scene/architecture/runtime/focus/runtime-providers/configured-providers";
|
||||
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
|
||||
expect(publishedMessages(socket)[0]?.hash)
|
||||
.toBe("#scene/architecture/runtime/focus/runtime-providers/configured-providers");
|
||||
|
||||
window.location.hash = "#scene/architecture/overview";
|
||||
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(2));
|
||||
await waitFor(() => expect(
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>(".figure-node"))
|
||||
.some((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? "")),
|
||||
).toBe(true));
|
||||
const capabilityInventoryNode = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(".figure-node"),
|
||||
).find((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? ""));
|
||||
if (capabilityInventoryNode === undefined) throw new Error("capability inventory figure node is missing");
|
||||
fireEvent.click(capabilityInventoryNode);
|
||||
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(3));
|
||||
expect(publishedMessages(socket)[2]?.hash)
|
||||
.toBe("#scene/architecture/overview/focus/runtime-providers");
|
||||
});
|
||||
|
||||
it("applies a remote discussion hash through the existing discussion panel without echoing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { sockets } = installSyncTransport();
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
const socket = await connectAudience(user, sockets);
|
||||
await act(async () => {
|
||||
socket.serverMessage(locationSnapshot("#discuss/hosted-automation", 1));
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("button", { name: /return to positioning/i })).toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.hash).toBe("#discuss/hosted-automation"));
|
||||
expect(publishedMessages(socket)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps local Space and ArrowRight navigation working when pairing fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { fetch } = installSyncTransport(false);
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
|
||||
await user.click(screen.getByRole("button", { name: "Start session" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The pairing server is unavailable.");
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" });
|
||||
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
|
||||
fireEvent.keyDown(document.body, { key: " " });
|
||||
await waitFor(() => expect(window.location.hash).toBe("#scene/problem/direct-actions"));
|
||||
});
|
||||
|
||||
const directHashRoutes = [
|
||||
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", hasDemoChrome: false },
|
||||
{ hash: "#scene/architecture/overview", heading: "Architecture Zoom", hasDemoChrome: false },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { hashForLocation, nextMainLocation } from "./storyboard-navigation.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
import type { DemoApprovalActions, DemoApprovalUiState } from "./demo-approval-actions.js";
|
||||
import { usePresentationSync } from "./sync/usePresentationSync.js";
|
||||
import "./presentation.css";
|
||||
import "./styles/demo-workflow.css";
|
||||
|
||||
@@ -43,6 +44,12 @@ export const PresentationRoute = () => {
|
||||
{ type: "jump_hash", hash: initialHash },
|
||||
),
|
||||
);
|
||||
const canonicalHash = hashForLocation(state.location);
|
||||
const presentationSync = usePresentationSync({
|
||||
role: "audience",
|
||||
currentHash: canonicalHash,
|
||||
applyRemoteHash: (hash) => dispatch({ type: "jump_hash", hash }),
|
||||
});
|
||||
const [presentationReady, setPresentationReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -306,6 +313,7 @@ export const PresentationRoute = () => {
|
||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||
openDiscussion={handleOpenDiscussion}
|
||||
closeDiscussion={handleCloseDiscussion}
|
||||
syncController={presentationSync}
|
||||
/>
|
||||
</PresentationCanvas>
|
||||
</main>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||
import { demoChromeFor } from "./presentation-demo-chrome.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import { findScene, type MainLocation } from "./storyboard.js";
|
||||
@@ -34,6 +35,7 @@ type PresentationStageProps = {
|
||||
readonly closeOverlay: () => void;
|
||||
readonly openDiscussion: (branchId: string) => void;
|
||||
readonly closeDiscussion: () => void;
|
||||
readonly syncController: PresentationSyncController;
|
||||
};
|
||||
|
||||
export const PresentationStage = ({
|
||||
@@ -55,6 +57,7 @@ export const PresentationStage = ({
|
||||
closeOverlay,
|
||||
openDiscussion,
|
||||
closeDiscussion,
|
||||
syncController,
|
||||
}: PresentationStageProps) => {
|
||||
const composition = compositionForState(state);
|
||||
|
||||
@@ -126,6 +129,7 @@ export const PresentationStage = ({
|
||||
retryHealth={retryHealth}
|
||||
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
||||
inspectEvidence={openEvidence}
|
||||
syncController={syncController}
|
||||
/>
|
||||
)}
|
||||
<EvidenceInspector
|
||||
|
||||
@@ -1111,6 +1111,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.presentation-footer__utility {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.evidence-receipt {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1217,6 +1225,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.presentation-footer__utility {
|
||||
flex: 0 1 auto;
|
||||
max-width: 52%;
|
||||
}
|
||||
|
||||
.presentation-demo-rail {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
Reference in New Issue
Block a user