mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* feat(slideshow): per-slide autoplay (manual-advance, opt-in) Adds an opt-in `autoplay` flag to slideshow slides: when the presenter lands on a video slide, its `<video>` plays from the start. The slideshow still holds and never auto-advances — the presenter clicks Next when ready. This covers compositions whose own controls can't be clicked (the player renders the composition pointer-events:none). Plumbing (done, tested): - core: `SlideRef.autoplay?: boolean`, parsed + validated in parseSlideshow (a non-boolean autoplay rejects the manifest); carried through resolve. - controller: optional `PlayerPort.playSceneMedia(sceneId)`, fired only on forward `enterSlide` for autoplay slides (not resume/back/sync, so the audience — which mirrors the presenter's media events — isn't double-driven). - component: `playSceneDocumentMedia` reaches the same-origin composition iframe, finds the scene's `<video>`, and asserts playback; `stopMedia` (already wired on slide change) resets it. An autoplay token cancels a pending start when the slide changes. - tests: controller autoplay behavior + parser flag round-trip/validation (131 player + 22 core slideshow tests pass). KNOWN LIMITATION — runtime media-start needs the player media model (@vance): On current main the clip<->timeline binding from #1601 keeps every clip synced and *paused* to the held timeline frame, which wins against playSceneMedia's play() — so the clip does not actually start on main yet (it does on the pre-#1601 player). The correct fix is a sanctioned "let this clip free-run while the timeline holds" path in the player/runtime media controller. Flagging for Vance to wire the start into the #1601 media model (or rebase onto it) when back. The plumbing above is the stable surface that hook plugs into. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(slideshow): address code-review on per-slide autoplay - guard playSceneDocumentMedia behind resolveMode() !== "audience": the audience mirrors the presenter's media events, so it must not independently drive its own copy of the clip. - drop the per-enter window pointerdown/keydown "gesture retry" listeners, which leaked when muted autoplay succeeded without a gesture. The poll already re-asserts play(), so a gesture within the window is picked up next tick. - stop polling once the clip is advancing across two ticks (was re-asserting play() for the full window even after playback was confirmed). - cancel any in-flight autoplay loop on disconnectedCallback (bump the token). - split the poll into findSceneVideo + stepAutoplay helpers (keeps each small). - fix the enterSlide comment: autoplay fires from enterSlide (next/prev/ goToSlide), not resumeSlide (back/backToMain/syncTo). - parser: isOptionalBoolean type guard instead of a one-off helper; drop `as` assertions in the new controller test. 131 player + 22 core slideshow tests pass; lint/format/typecheck/fallow clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(slideshow): autoplay skill guidance + address review nits Addresses review feedback on #1708: - skill: document per-slide `autoplay` in the slideshow standalone-harness reference — when to use it (video is the slide's primary content, its end is the advance cue) vs not (background/ambient loops, footage talked over), per Vance's guidance, before merge. - play() rejection is no longer blanket-swallowed: AbortError (timeline-sync seek interrupt) and NotAllowedError (gesture-gated autoplay) are expected and ignored; any other rejection is surfaced once via console.warn (Via nit 1). - clarify in the SlideRef.autoplay doc that it plays the scene's FIRST <video> (Via nit 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
731 lines
27 KiB
TypeScript
731 lines
27 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { SlideshowController } from "./SlideshowController";
|
|
import type { ResolvedSlideshow } from "@hyperframes/core/slideshow";
|
|
|
|
function fakePlayer() {
|
|
let cb: ((t: number) => void) | null = null;
|
|
const player = {
|
|
currentTime: 0,
|
|
seek: vi.fn((t: number) => {
|
|
player.currentTime = t;
|
|
}),
|
|
play: vi.fn(() => {}),
|
|
pause: vi.fn(() => {}),
|
|
stopMedia: vi.fn(() => {}),
|
|
playSceneMedia: vi.fn((_sceneId: string) => {}),
|
|
onTimeUpdate: (fn: (t: number) => void) => {
|
|
cb = fn;
|
|
return () => {
|
|
cb = null;
|
|
};
|
|
},
|
|
emit: (t: number) => {
|
|
player.currentTime = t;
|
|
cb?.(t);
|
|
},
|
|
};
|
|
return player;
|
|
}
|
|
|
|
const SHOW: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "a", start: 0, end: 5, fragments: [2, 4], hotspots: [] },
|
|
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {
|
|
deep: {
|
|
id: "deep",
|
|
label: "Deep dive",
|
|
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Factory: controller on SHOW, advanced to fragmentIndex=1. Construction enters
|
|
* slide a at fragmentIndex 0 (its first fragment); one next() reveals fragment 1.
|
|
* Navigation is synchronous (seek-driven) — no playback emit needed.
|
|
*/
|
|
function showAtFrag1() {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.next(); // fragmentIndex 0 → 1
|
|
return { p, c };
|
|
}
|
|
|
|
/**
|
|
* Factory: controller on SHOW, at slide 1, inside the "deep" branch.
|
|
* Used across branching + backToMain tests that share goToSlide(1)+enterBranch setup.
|
|
*/
|
|
function showAtSlide1InDeep() {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1);
|
|
c.enterBranch("deep");
|
|
return { p, c };
|
|
}
|
|
|
|
describe("SlideshowController linear nav", () => {
|
|
it("enters the first slide on construction: seeks to the first fragment (no auto-play)", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// Synchronous seek-only hold: jump to fragments[0]=2, fragmentIndex 0, never play.
|
|
expect(p.seek).toHaveBeenCalledWith(2);
|
|
expect(p.play).not.toHaveBeenCalled();
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(0);
|
|
});
|
|
|
|
it("never auto-plays — a single seek both repaints and holds", () => {
|
|
const p = fakePlayer();
|
|
new SlideshowController(p, SHOW);
|
|
// Determinism: navigation is a pure seek; the player is never put into a
|
|
// playing state that could run on into the next fragment/scene.
|
|
expect(p.play).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not stop media on construction or same-slide fragment navigation", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
expect(p.stopMedia).not.toHaveBeenCalled();
|
|
|
|
c.next(); // slide a fragment 0 -> fragment 1, same slide
|
|
expect(p.stopMedia).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops media before changing to another slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
|
|
c.next(); // fragment 0 -> fragment 1, same slide
|
|
c.next(); // slide a -> slide b
|
|
|
|
expect(p.stopMedia).toHaveBeenCalledOnce();
|
|
expect(p.seek).toHaveBeenLastCalledWith(7.5);
|
|
});
|
|
|
|
it("next stops at the first fragment, not the next slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// Construction already lands on fragment 0 of slide a.
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(0);
|
|
});
|
|
|
|
it("next past the last fragment advances to the next slide immediately", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// construction → fragment 0; next → fragment 1; next → slide b (no fragments)
|
|
c.next(); // fragmentIndex 0 → 1 (seek 4)
|
|
c.next(); // no more fragments — advance to slide b immediately
|
|
expect(c.position.slideIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
|
|
});
|
|
|
|
it("next() on a slide with NO fragments advances to the next slide immediately", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// Go to slide b (index 1, no fragments, not at end yet)
|
|
c.goToSlide(1); // slide b: start=5, end=10, fragments=[]
|
|
expect(c.position.slideIndex).toBe(1);
|
|
// The demo SHOW only has 2 slides, so next on slide 1 is a no-op.
|
|
// Use a show with a third slide to verify advancement.
|
|
const show3: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
|
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {},
|
|
};
|
|
const p2 = fakePlayer();
|
|
const c2 = new SlideshowController(p2, show3);
|
|
// slide 0 has no fragments; one next() should advance immediately to slide 1
|
|
c2.next();
|
|
expect(c2.position.slideIndex).toBe(1);
|
|
expect(p2.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
|
|
});
|
|
|
|
it("next() on the last slide is a no-op", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1); // slide b is last
|
|
c.next();
|
|
expect(c.position.slideIndex).toBe(1); // no change
|
|
});
|
|
|
|
it("prev returns to the previous slide start", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1);
|
|
c.prev();
|
|
expect(c.position.slideIndex).toBe(0);
|
|
});
|
|
|
|
it("at a fragment, next advances to the FOLLOWING fragment (not the end)", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
expect(c.position.fragmentIndex).toBe(0); // construction → fragment 0
|
|
c.next(); // should target fragments[1]=4, NOT slide.end=5
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(4);
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController nextSlide", () => {
|
|
it("returns the next slide when not at the end", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// At slide 0, next should be slide 1 (sceneId "b")
|
|
expect(c.nextSlide).not.toBeNull();
|
|
expect(c.nextSlide?.sceneId).toBe("b");
|
|
});
|
|
|
|
it("returns null when at the last slide in the sequence", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1); // slide "b" is the last in main
|
|
expect(c.nextSlide).toBeNull();
|
|
});
|
|
|
|
it("nextSlide is scoped to the current sequence", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.enterBranch("deep"); // "deep" has only one slide
|
|
expect(c.nextSlide).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController branching", () => {
|
|
it("enterBranch pushes onto the stack and enters the branch's first slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.enterBranch("deep");
|
|
expect(c.position.sequenceId).toBe("deep");
|
|
expect(c.currentSlide?.sceneId).toBe("c");
|
|
expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint
|
|
});
|
|
|
|
it("stops media when entering and leaving a branch", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
|
|
c.enterBranch("deep");
|
|
expect(p.stopMedia).toHaveBeenCalledTimes(1);
|
|
|
|
c.back();
|
|
expect(p.stopMedia).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("counter is scoped to the current sequence", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.enterBranch("deep");
|
|
expect(c.counter).toEqual({ index: 1, total: 1 });
|
|
});
|
|
|
|
it("breadcrumb reflects the stack", () => {
|
|
const { c } = showAtSlide1InDeep();
|
|
expect(c.breadcrumb.map((b) => b.label)).toEqual(["Main deck", "Deep dive"]);
|
|
});
|
|
|
|
it("back returns to the exact parent slide", () => {
|
|
const { c } = showAtSlide1InDeep();
|
|
c.back();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(1);
|
|
});
|
|
|
|
it("backToMain clears nested branches to the root", () => {
|
|
const { c } = showAtSlide1InDeep();
|
|
c.backToMain();
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
expect(c.position.slideIndex).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController — fragmentIndex advances synchronously on next()", () => {
|
|
it("construction lands on fragment 0; next() reveals fragment 1 immediately", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// Seek-only model: entering a fragmented slide shows its first fragment.
|
|
expect(c.position.fragmentIndex).toBe(0);
|
|
expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
|
|
c.next();
|
|
expect(c.position.fragmentIndex).toBe(1); // synchronous, no played tick
|
|
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1]
|
|
});
|
|
|
|
it("next() targets the FOLLOWING fragment (not slide end) while fragments remain", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.next(); // fragment 0 → 1 (fragments[1]=4, NOT slide.end=5)
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(4);
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", () => {
|
|
it("back() restores the saved fragmentIndex and seeks to the fragment time", () => {
|
|
const { p, c } = showAtFrag1();
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
// Enter branch — saves frame {main, slideIndex:0, fragmentIndex:1}
|
|
c.enterBranch("deep");
|
|
expect(c.position.sequenceId).toBe("deep");
|
|
// Back should restore main, slideIndex=0, fragmentIndex=1, seek to fragments[1]=4
|
|
c.back();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
|
|
});
|
|
|
|
it("resuming a fragmented slide at fragmentIndex -1 seeks to slide start", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// fragmentIndex -1 on a fragmented slide = before the first reveal. This state
|
|
// is reachable via syncTo (audience mirror); resume should seek to slide.start.
|
|
c.syncTo("main", 0, -1);
|
|
expect(c.position.fragmentIndex).toBe(-1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(0); // slide a start
|
|
});
|
|
|
|
it("back() to a NO-fragment parent slide resumes at its midpoint, not frame 0", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1); // slide b: [5,10], no fragments
|
|
c.enterBranch("deep");
|
|
c.back();
|
|
expect(c.position.slideIndex).toBe(1);
|
|
// Mirrors enterSlide's no-fragment rest frame (midpoint) so the slide is
|
|
// visible at rest instead of frozen at its pre-entrance frame-0.
|
|
expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint (5 + 5*0.5)
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController unknown-sequence degradation", () => {
|
|
it("enterBranch with an unknown id does not throw and leaves nav state unchanged", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.goToSlide(1);
|
|
expect(() => c.enterBranch("no-such-seq")).not.toThrow();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(1);
|
|
});
|
|
|
|
it("counter and currentSlide degrade gracefully when sequence is missing from show", () => {
|
|
// Construct a show where sequences has no entries, then verify slidesOf([missing])
|
|
// returns [] and counter/currentSlide do not throw.
|
|
const showNoSeq: ResolvedSlideshow = {
|
|
slides: [{ sceneId: "x", start: 0, end: 5, fragments: [], hotspots: [] }],
|
|
sequences: {},
|
|
};
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, showNoSeq);
|
|
// enterBranch guards — no bogus frame gets pushed. counter on main is safe.
|
|
expect(() => c.counter).not.toThrow();
|
|
expect(c.counter).toEqual({ index: 1, total: 1 });
|
|
// enterBranch with an unknown id: guard fires, state stays on main
|
|
expect(() => c.enterBranch("ghost")).not.toThrow();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
// breadcrumb does not throw for unknown sequence in stack (regression guard)
|
|
expect(() => c.breadcrumb).not.toThrow();
|
|
expect(c.breadcrumb[0]?.id).toBe("main");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug fix tests: #5-ctrl — enterSlide clears holdAt on empty-slide early return
|
|
// ---------------------------------------------------------------------------
|
|
describe("SlideshowController Fix #5-ctrl — enterBranch ignores empty branch", () => {
|
|
it("enterBranch into an empty sequence is a no-op (does not enter the branch)", () => {
|
|
// Build a show where "empty" sequence has no slides
|
|
const show: ResolvedSlideshow = {
|
|
slides: [{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] }],
|
|
sequences: {
|
|
empty: { id: "empty", label: "Empty", slides: [] },
|
|
},
|
|
};
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, show);
|
|
|
|
// Advance to a holdAt state by calling next() (sets holdAt to fragment 2)
|
|
c.next();
|
|
// Entering a branch that has no slides must be ignored — nav state unchanged.
|
|
c.enterBranch("empty");
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
});
|
|
|
|
it("enterSlide(0) on an empty main sequence does not throw", () => {
|
|
// This verifies the early-return path doesn't leave holdAt dirty
|
|
const show: ResolvedSlideshow = {
|
|
slides: [],
|
|
sequences: {},
|
|
};
|
|
const p = fakePlayer();
|
|
// Constructor calls enterSlide(0) — must not throw with empty slides
|
|
expect(() => new SlideshowController(p, show)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug fix tests: #backToMain — uses resumeSlide to preserve fragment position
|
|
// ---------------------------------------------------------------------------
|
|
describe("SlideshowController Fix #backToMain — restores fragment position like back()", () => {
|
|
it("backToMain restores the root frame's fragmentIndex (not reset to -1)", () => {
|
|
const { p, c } = showAtFrag1();
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
|
|
// Enter branch — saves root frame with fragmentIndex=1
|
|
c.enterBranch("deep");
|
|
expect(c.position.sequenceId).toBe("deep");
|
|
|
|
// backToMain should restore to main slideIndex=0, fragmentIndex=1 (not -1)
|
|
c.backToMain();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
// resumeSlide seeks to the fragment time (fragments[1]=4)
|
|
expect(p.seek).toHaveBeenLastCalledWith(4);
|
|
});
|
|
|
|
it("backToMain restores the root fragment the branch was entered from", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
|
|
// Construction lands on slide a fragment 0; enter a branch, then return.
|
|
c.enterBranch("deep");
|
|
c.backToMain();
|
|
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(0);
|
|
expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
|
|
});
|
|
|
|
it("backToMain with multiple nested branches restores root slide position", () => {
|
|
const show: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] },
|
|
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {
|
|
lvl1: {
|
|
id: "lvl1",
|
|
label: "Level 1",
|
|
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
|
|
},
|
|
},
|
|
};
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, show);
|
|
c.goToSlide(1); // root at slide 1
|
|
c.enterBranch("lvl1");
|
|
|
|
// backToMain must pop all frames back to root
|
|
c.backToMain();
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Branch-edge navigation: prev/next at branch boundaries return to parent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Show used only for branch-edge tests: 2 main slides + single- and multi-slide branches.
|
|
const SHOW_BRANCH_EDGE: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
|
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {
|
|
single: {
|
|
id: "single",
|
|
label: "Single slide branch",
|
|
slides: [{ sceneId: "x", start: 10, end: 13, fragments: [], hotspots: [] }],
|
|
},
|
|
multi: {
|
|
id: "multi",
|
|
label: "Multi slide branch",
|
|
slides: [
|
|
{ sceneId: "y", start: 13, end: 16, fragments: [], hotspots: [] },
|
|
{ sceneId: "z", start: 16, end: 20, fragments: [], hotspots: [] },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
/** Factory: controller on SHOW_BRANCH_EDGE, already inside the given branch. */
|
|
function inBranch(branchId: string): { c: SlideshowController } {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
|
c.enterBranch(branchId);
|
|
return { c };
|
|
}
|
|
|
|
describe("SlideshowController branch-edge nav — prev/next return to parent", () => {
|
|
it("single-slide branch: prev() returns to parent", () => {
|
|
const { c } = inBranch("single");
|
|
expect(c.breadcrumb.length).toBe(2);
|
|
c.prev();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
|
|
it("single-slide branch: next() (no fragments, last slide) returns to parent", () => {
|
|
const { c } = inBranch("single");
|
|
expect(c.breadcrumb.length).toBe(2);
|
|
c.next();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
|
|
it("multi-slide branch: prev() from slide 1 → slide 0, NOT popped", () => {
|
|
const { c } = inBranch("multi");
|
|
c.goToSlide(1);
|
|
c.prev();
|
|
expect(c.position.sequenceId).toBe("multi");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.breadcrumb.length).toBe(2);
|
|
});
|
|
|
|
it("multi-slide branch: prev() from slide 0 → parent", () => {
|
|
const { c } = inBranch("multi");
|
|
c.prev();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
|
|
it("multi-slide branch: next() from slide 0 → slide 1, NOT popped", () => {
|
|
const { c } = inBranch("multi");
|
|
c.next();
|
|
expect(c.position.sequenceId).toBe("multi");
|
|
expect(c.position.slideIndex).toBe(1);
|
|
expect(c.breadcrumb.length).toBe(2);
|
|
});
|
|
|
|
it("multi-slide branch: next() from slide 1 (last) → parent", () => {
|
|
const { c } = inBranch("multi");
|
|
c.goToSlide(1);
|
|
c.next();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
|
|
it("main line: prev() at slide 0 is a no-op (stack.length === 1)", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
|
c.prev();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
|
|
it("main line: next() at last slide is a no-op (does NOT call back)", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
|
c.goToSlide(1);
|
|
c.next();
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(1);
|
|
expect(c.breadcrumb.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// canPrev / canNext getters
|
|
// ---------------------------------------------------------------------------
|
|
describe("SlideshowController canPrev / canNext", () => {
|
|
it("main first slide: canPrev=false, canNext=true", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
|
expect(c.canPrev).toBe(false);
|
|
expect(c.canNext).toBe(true);
|
|
});
|
|
|
|
it("main last slide: canPrev=true, canNext=false", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
|
c.goToSlide(1); // last slide (total=2)
|
|
expect(c.canPrev).toBe(true);
|
|
expect(c.canNext).toBe(false);
|
|
});
|
|
|
|
it("main middle slide: canPrev=true, canNext=true", () => {
|
|
// Use SHOW (3+ slides via SHOW_BRANCH_EDGE is only 2; use a 3-slide show)
|
|
const threeSlideShow: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
|
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {},
|
|
};
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, threeSlideShow);
|
|
c.goToSlide(1);
|
|
expect(c.canPrev).toBe(true);
|
|
expect(c.canNext).toBe(true);
|
|
});
|
|
|
|
it("single-slide main: canPrev=false, canNext=false", () => {
|
|
const oneSlide: ResolvedSlideshow = {
|
|
slides: [{ sceneId: "only", start: 0, end: 5, fragments: [], hotspots: [] }],
|
|
sequences: {},
|
|
};
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, oneSlide);
|
|
expect(c.canPrev).toBe(false);
|
|
expect(c.canNext).toBe(false);
|
|
});
|
|
|
|
it("inside a branch (first slide): canPrev=true (parent is prev), canNext=true (next-within or parent)", () => {
|
|
const { c } = inBranch("single");
|
|
// single-slide branch, slideIndex=0, stack.length=2
|
|
expect(c.canPrev).toBe(true);
|
|
expect(c.canNext).toBe(true);
|
|
});
|
|
|
|
it("inside a multi-slide branch (first slide): canPrev=true, canNext=true", () => {
|
|
const { c } = inBranch("multi");
|
|
// slideIndex=0, next slide exists within branch
|
|
expect(c.canPrev).toBe(true);
|
|
expect(c.canNext).toBe(true);
|
|
});
|
|
|
|
it("inside a multi-slide branch (last slide): canPrev=true, canNext=true (parent is next)", () => {
|
|
const { c } = inBranch("multi");
|
|
c.goToSlide(1); // last slide in branch
|
|
expect(c.canPrev).toBe(true);
|
|
expect(c.canNext).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// next() reveals remaining fragments even when playback is already at slide end
|
|
// (the atEnd gate was removed so a no-animation jump to slide end still steps
|
|
// through pending fragments rather than skipping straight to the next slide).
|
|
// ---------------------------------------------------------------------------
|
|
describe("SlideshowController next() — reveals remaining fragments at slide end", () => {
|
|
it("reveals the next fragment even when currentTime is already at slide end", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
// Static jump to slide end; pending fragments should still be revealed in order
|
|
// (the playhead position doesn't gate fragment stepping).
|
|
p.currentTime = 5; // slide a end
|
|
c.next(); // fragment 0 → 1, stays on slide a
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1], not slide b
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// syncTo — absolute, animation-free position mirroring for the audience window.
|
|
// ---------------------------------------------------------------------------
|
|
describe("SlideshowController syncTo", () => {
|
|
it("re-roots to a branch sequence and restores slide+fragment statically", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.syncTo("deep", 0, -1);
|
|
expect(c.position.sequenceId).toBe("deep");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
// Slide c has no fragments, so resumeSlide lands at its midpoint (restFrame) —
|
|
// the same visible-at-rest position enterSlide uses — not slide start. A single
|
|
// seek both repaints and holds (no sustained playback).
|
|
expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint (10 + 3*0.5)
|
|
expect(p.play).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("syncs a main-line slide+fragment position without animating", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.syncTo("main", 0, 1); // slide a, fragmentIndex 1 → fragments[1]=4
|
|
expect(c.position.sequenceId).toBe("main");
|
|
expect(c.position.slideIndex).toBe(0);
|
|
expect(c.position.fragmentIndex).toBe(1);
|
|
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
|
|
});
|
|
|
|
it("does not stop media when syncing only the fragment within the same slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
|
|
c.syncTo("main", 0, 1);
|
|
|
|
expect(p.stopMedia).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops media when audience sync moves to a different slide or sequence", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
|
|
c.syncTo("main", 1, -1);
|
|
expect(p.stopMedia).toHaveBeenCalledTimes(1);
|
|
|
|
c.syncTo("deep", 0, -1);
|
|
expect(p.stopMedia).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("ignores an unknown sequence target", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.syncTo("nope", 0, -1);
|
|
expect(c.position.sequenceId).toBe("main");
|
|
});
|
|
|
|
it("ignores an out-of-range slide index", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, SHOW);
|
|
c.syncTo("main", 99, -1);
|
|
expect(c.position.slideIndex).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("SlideshowController autoplay", () => {
|
|
// "v" autoplays; "w" does not. Both sit on the main line.
|
|
const AUTOPLAY_SHOW: ResolvedSlideshow = {
|
|
slides: [
|
|
{ sceneId: "v", start: 0, end: 5, fragments: [], hotspots: [], autoplay: true },
|
|
{ sceneId: "w", start: 5, end: 10, fragments: [], hotspots: [] },
|
|
],
|
|
sequences: {},
|
|
};
|
|
|
|
it("plays the slide's media on enter when autoplay is set", () => {
|
|
const p = fakePlayer();
|
|
new SlideshowController(p, AUTOPLAY_SHOW); // constructs on slide "v"
|
|
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
|
|
expect(p.playSceneMedia).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not play media when entering a non-autoplay slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, AUTOPLAY_SHOW);
|
|
p.playSceneMedia.mockClear();
|
|
c.next(); // v → w (w is not autoplay)
|
|
expect(c.position.slideIndex).toBe(1);
|
|
expect(p.playSceneMedia).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops prior media and plays again when navigating back into an autoplay slide", () => {
|
|
const p = fakePlayer();
|
|
const c = new SlideshowController(p, AUTOPLAY_SHOW);
|
|
p.playSceneMedia.mockClear();
|
|
c.next(); // → w
|
|
expect(p.stopMedia).toHaveBeenCalled(); // leaving v stops its clip
|
|
p.playSceneMedia.mockClear();
|
|
c.prev(); // back into v (enterSlide) → replays
|
|
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
|
|
});
|
|
|
|
it("does not require autoplay support on the port (optional hook)", () => {
|
|
// A port without playSceneMedia must not throw when entering an autoplay slide.
|
|
const { playSceneMedia: _omitted, ...port } = fakePlayer();
|
|
expect(() => new SlideshowController(port, AUTOPLAY_SHOW)).not.toThrow();
|
|
});
|
|
});
|