638 lines
20 KiB
TypeScript
638 lines
20 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
INITIAL_VIEWER_PRESENTATION_STATE,
|
|
deriveViewerPresentation,
|
|
reduceViewerPresentation,
|
|
viewerFailureFromServerCode,
|
|
type ViewerPresentationAction,
|
|
type ViewerPresentationState,
|
|
} from "../src/client/media/viewer-presentation.ts";
|
|
|
|
function apply(
|
|
...actions: readonly ViewerPresentationAction[]
|
|
): ViewerPresentationState {
|
|
return applyFrom(INITIAL_VIEWER_PRESENTATION_STATE, ...actions);
|
|
}
|
|
|
|
function applyFrom(
|
|
state: ViewerPresentationState,
|
|
...actions: readonly ViewerPresentationAction[]
|
|
): ViewerPresentationState {
|
|
return actions.reduce(reduceViewerPresentation, state);
|
|
}
|
|
|
|
describe("Viewer presentation reducer", () => {
|
|
it("derives connection stages from typed current-revision facts", () => {
|
|
const preparing = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 1, phase: "prepare", kind: "p2p" },
|
|
);
|
|
expect(deriveViewerPresentation(preparing)).toMatchObject({
|
|
stage: "preparing-p2p",
|
|
messageKey: "viewer.msg.preparingP2p",
|
|
overlay: "blocking",
|
|
});
|
|
|
|
const receiving = reduceViewerPresentation(preparing, {
|
|
type: "media-bound",
|
|
generation: 1,
|
|
revision: 1,
|
|
});
|
|
expect(deriveViewerPresentation(receiving).stage).toBe("receiving");
|
|
|
|
const playing = reduceViewerPresentation(receiving, {
|
|
type: "frame-presented",
|
|
generation: 1,
|
|
revision: 1,
|
|
});
|
|
expect(deriveViewerPresentation(playing)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
hasCurrentFrame: true,
|
|
connectionState: "connected",
|
|
});
|
|
});
|
|
|
|
it("reports native playback blocking only for the exact media generation", () => {
|
|
const blocked = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 2, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 4, revision: 2 },
|
|
{ type: "autoplay-blocked", generation: 4, revision: 2 },
|
|
{ type: "connection", revision: 2, connection: "connected" },
|
|
);
|
|
expect(deriveViewerPresentation(blocked)).toMatchObject({
|
|
stage: "needs-play",
|
|
messageKey: "viewer.msg.needsPlay",
|
|
overlay: "status",
|
|
failureCode: "AUTOPLAY_BLOCKED",
|
|
});
|
|
|
|
const staleClear = reduceViewerPresentation(blocked, {
|
|
type: "autoplay-cleared",
|
|
generation: 3,
|
|
});
|
|
expect(deriveViewerPresentation(staleClear).stage).toBe("needs-play");
|
|
|
|
const cleared = reduceViewerPresentation(blocked, {
|
|
type: "autoplay-cleared",
|
|
generation: 4,
|
|
});
|
|
expect(deriveViewerPresentation(cleared)).toMatchObject({
|
|
stage: "receiving",
|
|
});
|
|
});
|
|
|
|
it("waits for the current media connection before reporting playback blocking", () => {
|
|
const blockedWhileConnecting = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 2, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 4, revision: 2 },
|
|
{ type: "autoplay-blocked", generation: 4, revision: 2 },
|
|
);
|
|
expect(deriveViewerPresentation(blockedWhileConnecting)).toMatchObject({
|
|
stage: "receiving",
|
|
});
|
|
|
|
const connected = reduceViewerPresentation(blockedWhileConnecting, {
|
|
type: "connection",
|
|
revision: 2,
|
|
connection: "connected",
|
|
});
|
|
expect(deriveViewerPresentation(connected)).toMatchObject({
|
|
stage: "needs-play",
|
|
overlay: "status",
|
|
});
|
|
});
|
|
|
|
it("does not reuse a connected fact after the route revision advances", () => {
|
|
const connected = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 2, phase: "active", kind: "p2p" },
|
|
{ type: "connection", revision: 2, connection: "connected" },
|
|
);
|
|
const nextRoute = reduceViewerPresentation(connected, {
|
|
type: "route",
|
|
revision: 3,
|
|
phase: "prepare",
|
|
kind: "sfu",
|
|
});
|
|
expect(nextRoute.connection).toBe("connecting");
|
|
});
|
|
|
|
it("keeps proven media independent of room graph revision changes", () => {
|
|
const playing = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 2, phase: "active", kind: "p2p" },
|
|
{ type: "connection", revision: 2, connection: "connected" },
|
|
{ type: "media-bound", generation: 4, revision: 2 },
|
|
{ type: "frame-presented", generation: 4, revision: 2 },
|
|
);
|
|
const rebased = reduceViewerPresentation(playing, {
|
|
type: "route",
|
|
revision: 3,
|
|
phase: "active",
|
|
kind: "p2p",
|
|
});
|
|
|
|
expect(rebased.media).toEqual({
|
|
generation: 4,
|
|
boundAtRevision: 2,
|
|
framePresented: true,
|
|
});
|
|
expect(rebased.connection).toBe("connected");
|
|
expect(deriveViewerPresentation(rebased)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
hasCurrentFrame: true,
|
|
hasRetainedFrame: false,
|
|
});
|
|
});
|
|
|
|
it("ignores a late playback failure from a replaced media binding", () => {
|
|
const rebound = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 2, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 1, revision: 2 },
|
|
{ type: "media-bound", generation: 2, revision: 2 },
|
|
);
|
|
const staleFailure = reduceViewerPresentation(rebound, {
|
|
type: "playback-failed",
|
|
generation: 1,
|
|
revision: 2,
|
|
});
|
|
expect(staleFailure).toBe(rebound);
|
|
expect(deriveViewerPresentation(staleFailure).stage).toBe("receiving");
|
|
});
|
|
|
|
it("keeps playback authority with the bound media generation", () => {
|
|
const oldMedia = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 5, revision: 5 },
|
|
{ type: "frame-presented", generation: 5, revision: 5 },
|
|
);
|
|
const preparing = reduceViewerPresentation(oldMedia, {
|
|
type: "route",
|
|
revision: 6,
|
|
phase: "prepare",
|
|
kind: "sfu",
|
|
});
|
|
|
|
const staleAutoplay = reduceViewerPresentation(preparing, {
|
|
type: "autoplay-blocked",
|
|
generation: 5,
|
|
revision: 5,
|
|
});
|
|
const staleFailure = reduceViewerPresentation(preparing, {
|
|
type: "playback-failed",
|
|
generation: 5,
|
|
revision: 5,
|
|
});
|
|
|
|
expect(staleAutoplay).toMatchObject({
|
|
autoplayBlockedGeneration: 5,
|
|
failure: "AUTOPLAY_BLOCKED",
|
|
});
|
|
expect(staleFailure).toMatchObject({ failure: "PLAYBACK_FAILED" });
|
|
expect(deriveViewerPresentation(preparing)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
});
|
|
});
|
|
|
|
it("retains autoplay authority until the media generation changes", () => {
|
|
const blocked = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 5, revision: 5 },
|
|
{ type: "autoplay-blocked", generation: 5, revision: 5 },
|
|
);
|
|
const preparing = reduceViewerPresentation(blocked, {
|
|
type: "route",
|
|
revision: 6,
|
|
phase: "prepare",
|
|
kind: "sfu",
|
|
});
|
|
|
|
expect(preparing.autoplayBlockedGeneration).toBe(5);
|
|
expect(preparing.failure).toBe("AUTOPLAY_BLOCKED");
|
|
expect(deriveViewerPresentation(preparing)).toMatchObject({
|
|
stage: "receiving",
|
|
});
|
|
});
|
|
|
|
it("keeps a proven frame until a replacement media generation binds", () => {
|
|
const oldFrame = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 5, revision: 5 },
|
|
{ type: "frame-presented", generation: 5, revision: 5 },
|
|
);
|
|
const recovering = reduceViewerPresentation(oldFrame, {
|
|
type: "route",
|
|
revision: 6,
|
|
phase: "prepare",
|
|
kind: "sfu",
|
|
});
|
|
expect(deriveViewerPresentation(recovering)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
hasCurrentFrame: true,
|
|
hasRetainedFrame: false,
|
|
});
|
|
|
|
const continuingProof = reduceViewerPresentation(recovering, {
|
|
type: "frame-presented",
|
|
generation: 5,
|
|
revision: 5,
|
|
});
|
|
expect(continuingProof).toMatchObject({
|
|
media: { generation: 5, framePresented: true },
|
|
});
|
|
|
|
const rebound = reduceViewerPresentation(recovering, {
|
|
type: "media-bound",
|
|
generation: 6,
|
|
revision: 6,
|
|
});
|
|
const proven = reduceViewerPresentation(rebound, {
|
|
type: "frame-presented",
|
|
generation: 6,
|
|
revision: 6,
|
|
});
|
|
expect(deriveViewerPresentation(proven)).toMatchObject({
|
|
stage: "playing",
|
|
hasCurrentFrame: true,
|
|
hasRetainedFrame: false,
|
|
});
|
|
});
|
|
|
|
it("keeps healthy media visible while signaling reconnects", () => {
|
|
const state = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 0, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 1, revision: 0 },
|
|
{ type: "frame-presented", generation: 1, revision: 0 },
|
|
{ type: "signal", signal: "reconnecting" },
|
|
);
|
|
expect(deriveViewerPresentation(state)).toMatchObject({
|
|
stage: "recovering",
|
|
overlay: "none",
|
|
noticeKey: "viewer.notice.signalRecovering",
|
|
});
|
|
});
|
|
|
|
it("treats a new current-generation frame as connected media", () => {
|
|
const recovering = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "connection", revision: 3, connection: "reconnecting" },
|
|
);
|
|
|
|
const resumed = reduceViewerPresentation(recovering, {
|
|
type: "frame-presented",
|
|
generation: 2,
|
|
revision: 3,
|
|
});
|
|
expect(resumed.connection).toBe("connected");
|
|
expect(deriveViewerPresentation(resumed)).toMatchObject({
|
|
stage: "playing",
|
|
messageKey: "viewer.msg.playing",
|
|
noticeKey: null,
|
|
});
|
|
});
|
|
|
|
it("re-arms current media when active authority returns after route failure", () => {
|
|
const failed = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "frame-presented", generation: 2, revision: 3 },
|
|
{ type: "route-status", revision: 3, state: "failed" },
|
|
);
|
|
const recovering = reduceViewerPresentation(failed, {
|
|
type: "route",
|
|
revision: 4,
|
|
phase: "active",
|
|
kind: "sfu",
|
|
});
|
|
expect(recovering).toMatchObject({
|
|
connection: "reconnecting",
|
|
routeStatus: null,
|
|
failure: null,
|
|
media: { generation: 2, framePresented: false },
|
|
});
|
|
|
|
const resumed = reduceViewerPresentation(recovering, {
|
|
type: "frame-presented",
|
|
generation: 2,
|
|
revision: 3,
|
|
});
|
|
expect(resumed.connection).toBe("connected");
|
|
expect(deriveViewerPresentation(resumed)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
});
|
|
});
|
|
|
|
it("keeps a browser playback failure across unrelated active authority", () => {
|
|
const failed = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "playback-failed", generation: 2, revision: 3 },
|
|
);
|
|
const rebased = reduceViewerPresentation(failed, {
|
|
type: "route",
|
|
revision: 4,
|
|
phase: "active",
|
|
kind: "sfu",
|
|
});
|
|
|
|
expect(rebased.failure).toBe("PLAYBACK_FAILED");
|
|
expect(deriveViewerPresentation(rebased).stage).toBe("playback-failed");
|
|
});
|
|
|
|
it("keeps a healthy Host-offline frame but invalidates it on upstream failure", () => {
|
|
const offlineWithMedia = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "frame-presented", generation: 2, revision: 3 },
|
|
{ type: "host", host: "offline" },
|
|
);
|
|
expect(deriveViewerPresentation(offlineWithMedia)).toMatchObject({
|
|
stage: "playing",
|
|
overlay: "none",
|
|
hasCurrentFrame: true,
|
|
noticeKey: "viewer.notice.hostOffline",
|
|
});
|
|
expect(
|
|
reduceViewerPresentation(offlineWithMedia, {
|
|
type: "media-invalidated",
|
|
revision: 2,
|
|
}),
|
|
).toBe(offlineWithMedia);
|
|
|
|
const failed = reduceViewerPresentation(offlineWithMedia, {
|
|
type: "media-invalidated",
|
|
revision: 3,
|
|
});
|
|
expect(deriveViewerPresentation(failed)).toMatchObject({
|
|
stage: "host-offline",
|
|
overlay: "status",
|
|
hasCurrentFrame: false,
|
|
hasRetainedFrame: true,
|
|
failureCode: "HOST_OFFLINE",
|
|
});
|
|
});
|
|
|
|
it("retains the current frame while signaling reconnects", () => {
|
|
const reconnecting = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "frame-presented", generation: 2, revision: 3 },
|
|
{ type: "signal", signal: "reconnecting" },
|
|
);
|
|
|
|
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
|
|
stage: "recovering",
|
|
messageKey: "viewer.msg.recovering",
|
|
overlay: "none",
|
|
noticeKey: "viewer.notice.signalRecovering",
|
|
});
|
|
});
|
|
|
|
it("requires a new composited frame after presentation proof resets", () => {
|
|
const playing = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 2, revision: 3 },
|
|
{ type: "frame-presented", generation: 2, revision: 3 },
|
|
);
|
|
const reset = reduceViewerPresentation(playing, {
|
|
type: "frame-proof-reset",
|
|
generation: 2,
|
|
revision: 3,
|
|
});
|
|
|
|
expect(deriveViewerPresentation(reset)).toMatchObject({
|
|
hasCurrentFrame: false,
|
|
hasRetainedFrame: true,
|
|
});
|
|
expect(
|
|
deriveViewerPresentation(
|
|
reduceViewerPresentation(reset, {
|
|
type: "connection",
|
|
revision: 3,
|
|
connection: "reconnecting",
|
|
}),
|
|
).stage,
|
|
).toBe("recovering");
|
|
expect(
|
|
reduceViewerPresentation(reset, {
|
|
type: "frame-proof-reset",
|
|
generation: 1,
|
|
revision: 3,
|
|
}),
|
|
).toBe(reset);
|
|
expect(
|
|
reduceViewerPresentation(reset, {
|
|
type: "frame-presented",
|
|
generation: 2,
|
|
revision: 3,
|
|
}).media?.framePresented,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("demotes the current frame when an exact route terminally fails", () => {
|
|
const playing = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "signal", signal: "connected" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 4, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 3, revision: 4 },
|
|
{ type: "frame-presented", generation: 3, revision: 4 },
|
|
);
|
|
const failed = reduceViewerPresentation(playing, {
|
|
type: "route-status",
|
|
revision: 4,
|
|
state: "failed",
|
|
});
|
|
|
|
expect(deriveViewerPresentation(failed)).toMatchObject({
|
|
stage: "route-failed",
|
|
messageKey: "viewer.msg.routeFailed",
|
|
overlay: "status",
|
|
hasCurrentFrame: false,
|
|
hasRetainedFrame: true,
|
|
failureCode: "ROUTE_EXHAUSTED",
|
|
connectionState: "failed",
|
|
});
|
|
|
|
const lateFrame = reduceViewerPresentation(failed, {
|
|
type: "frame-presented",
|
|
generation: 3,
|
|
revision: 4,
|
|
});
|
|
expect(lateFrame).toBe(failed);
|
|
expect(
|
|
reduceViewerPresentation(failed, {
|
|
type: "media-bound",
|
|
generation: 4,
|
|
revision: 4,
|
|
}),
|
|
).toBe(failed);
|
|
const restored = reduceViewerPresentation(failed, {
|
|
type: "route",
|
|
revision: 4,
|
|
phase: "active",
|
|
kind: "sfu",
|
|
});
|
|
expect(restored.routeStatus).toBeNull();
|
|
expect(restored.failure).toBeNull();
|
|
expect(
|
|
reduceViewerPresentation(restored, {
|
|
type: "frame-presented",
|
|
generation: 3,
|
|
revision: 4,
|
|
}).media?.framePresented,
|
|
).toBe(true);
|
|
|
|
const localFailure = reduceViewerPresentation(playing, {
|
|
type: "failure",
|
|
failure: "ROUTE_EXHAUSTED",
|
|
revision: 4,
|
|
});
|
|
expect(
|
|
reduceViewerPresentation(localFailure, {
|
|
type: "media-bound",
|
|
generation: 4,
|
|
revision: 4,
|
|
}),
|
|
).toBe(localFailure);
|
|
});
|
|
|
|
it("honors terminal access, Host pause, and typed route status priority", () => {
|
|
const paused = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "paused" },
|
|
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
|
|
{ type: "media-bound", generation: 1, revision: 3 },
|
|
{ type: "autoplay-blocked", generation: 1, revision: 3 },
|
|
);
|
|
expect(deriveViewerPresentation(paused)).toMatchObject({
|
|
stage: "host-paused",
|
|
});
|
|
|
|
const waiting = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route-status", revision: 4, state: "waiting" },
|
|
);
|
|
expect(deriveViewerPresentation(waiting).stage).toBe("waiting-sfu");
|
|
|
|
const failed = reduceViewerPresentation(waiting, {
|
|
type: "route-status",
|
|
revision: 4,
|
|
state: "failed",
|
|
});
|
|
expect(deriveViewerPresentation(failed)).toMatchObject({
|
|
stage: "route-failed",
|
|
failureCode: "ROUTE_EXHAUSTED",
|
|
});
|
|
|
|
const denied = reduceViewerPresentation(failed, {
|
|
type: "access",
|
|
access: "denied",
|
|
failure: "ROOM_ACCESS_DENIED",
|
|
});
|
|
expect(deriveViewerPresentation(denied)).toMatchObject({
|
|
stage: "access-denied",
|
|
messageKey: "viewer.msg.denied",
|
|
});
|
|
|
|
const notFound = reduceViewerPresentation(failed, {
|
|
type: "access",
|
|
access: "denied",
|
|
failure: "ROOM_NOT_FOUND",
|
|
});
|
|
expect(deriveViewerPresentation(notFound)).toMatchObject({
|
|
stage: "room-not-found",
|
|
messageKey: "viewer.msg.notFound",
|
|
});
|
|
});
|
|
|
|
it("preserves exact Viewer access failures without inferring a restart", () => {
|
|
expect(viewerFailureFromServerCode("INVALID_TOKEN")).toBe("INVALID_TOKEN");
|
|
expect(viewerFailureFromServerCode("ROOM_NOT_FOUND")).toBe(
|
|
"ROOM_NOT_FOUND",
|
|
);
|
|
expect(viewerFailureFromServerCode("ROOM_ACCESS_DENIED")).toBe(
|
|
"ROOM_ACCESS_DENIED",
|
|
);
|
|
});
|
|
|
|
it("starts a fresh route revision namespace after sharing stops", () => {
|
|
const previousShare = apply(
|
|
{ type: "access", access: "ready" },
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 8, phase: "active", kind: "p2p" },
|
|
{ type: "media-bound", generation: 2, revision: 8 },
|
|
{ type: "frame-presented", generation: 2, revision: 8 },
|
|
);
|
|
const stopped = reduceViewerPresentation(previousShare, {
|
|
type: "sharing-stopped",
|
|
});
|
|
expect(stopped).toMatchObject({
|
|
host: "stopped",
|
|
revision: null,
|
|
route: null,
|
|
connection: "idle",
|
|
media: null,
|
|
retainedFrame: false,
|
|
failure: "HOST_STOPPED",
|
|
});
|
|
|
|
const restarted = applyFrom(stopped,
|
|
{ type: "host", host: "online" },
|
|
{ type: "route", revision: 1, phase: "prepare", kind: "p2p" },
|
|
{ type: "media-bound", generation: 3, revision: 1 },
|
|
{ type: "frame-presented", generation: 3, revision: 1 },
|
|
);
|
|
expect(deriveViewerPresentation(restarted)).toMatchObject({
|
|
stage: "playing",
|
|
messageKey: "viewer.msg.playing",
|
|
overlay: "none",
|
|
});
|
|
});
|
|
});
|