Files
hyperframes/packages/studio/src/player/store/playerStore.test.ts
T
Carlos Alcaraz GregorandCarlos Alcaraz 83c29faaf9 fix(studio): auto-enable loop when work-area markers are set (#859)
Setting an in or out point now turns on loopEnabled so the playhead
respects the marker instead of running past the out-point. Closes the
last open sub-bug of #834.

Background: PR #811 wired the work-area RAF loop to read inPoint/outPoint
but kept the loop branch gated behind loopEnabled. Default for that flag
is false, so users who set markers without first toggling the loop button
saw playback sail past the out-point (or, with the L shuttle, overshoot
by a few frames before pausing). The original spec for the feature in
issue #807 described markers as logic that "constrains the playback
engine"; the actual UX did not match that until the toggle was on.

Fix: setInPoint and setOutPoint flip loopEnabled to true when given a
non-null value. This sits next to the existing "smart setter" behavior
already in the store (setting one marker past the other nullifies the
counterpart). Clearing a marker with null preserves the current
loopEnabled, so a user who manually toggles the loop button stays in
control after that point.

Tests: full coverage for setInPoint and setOutPoint (none existed
before), including overlap nullification, non-finite rejection,
auto-enable on set, and preserve-on-clear in both directions.

Closes #834

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-15 06:42:32 +02:00

398 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
describe("usePlayerStore", () => {
beforeEach(() => {
usePlayerStore.getState().reset();
});
describe("initial state", () => {
it("has correct defaults", () => {
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expect(state.playbackRate).toBe(1);
expect(state.audioMuted).toBe(false);
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit");
expect(state.manualZoomPercent).toBe(100);
});
});
describe("setIsPlaying", () => {
it("sets isPlaying to true", () => {
usePlayerStore.getState().setIsPlaying(true);
expect(usePlayerStore.getState().isPlaying).toBe(true);
});
it("sets isPlaying to false", () => {
usePlayerStore.getState().setIsPlaying(true);
usePlayerStore.getState().setIsPlaying(false);
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
});
describe("setCurrentTime", () => {
it("updates currentTime", () => {
usePlayerStore.getState().setCurrentTime(12.5);
expect(usePlayerStore.getState().currentTime).toBe(12.5);
});
it("accepts zero", () => {
usePlayerStore.getState().setCurrentTime(42);
usePlayerStore.getState().setCurrentTime(0);
expect(usePlayerStore.getState().currentTime).toBe(0);
});
});
describe("setDuration", () => {
it("updates duration", () => {
usePlayerStore.getState().setDuration(120);
expect(usePlayerStore.getState().duration).toBe(120);
});
});
describe("setPlaybackRate", () => {
it("updates playbackRate", () => {
usePlayerStore.getState().setPlaybackRate(2);
expect(usePlayerStore.getState().playbackRate).toBe(2);
});
});
describe("setAudioMuted", () => {
it("updates audioMuted", () => {
usePlayerStore.getState().setAudioMuted(true);
expect(usePlayerStore.getState().audioMuted).toBe(true);
});
});
describe("setLoopEnabled", () => {
it("updates loopEnabled", () => {
usePlayerStore.getState().setLoopEnabled(true);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
});
});
describe("setInPoint", () => {
it("updates inPoint", () => {
usePlayerStore.getState().setInPoint(1.5);
expect(usePlayerStore.getState().inPoint).toBe(1.5);
});
it("clears inPoint when given null", () => {
usePlayerStore.getState().setInPoint(1.5);
usePlayerStore.getState().setInPoint(null);
expect(usePlayerStore.getState().inPoint).toBeNull();
});
it("rejects non-finite values", () => {
usePlayerStore.getState().setInPoint(Number.NaN);
expect(usePlayerStore.getState().inPoint).toBeNull();
});
it("nullifies outPoint when new inPoint is at or past existing outPoint", () => {
usePlayerStore.getState().setOutPoint(2);
usePlayerStore.getState().setInPoint(3);
expect(usePlayerStore.getState().outPoint).toBeNull();
expect(usePlayerStore.getState().inPoint).toBe(3);
});
it("preserves outPoint when new inPoint is before it", () => {
usePlayerStore.getState().setOutPoint(5);
usePlayerStore.getState().setInPoint(2);
expect(usePlayerStore.getState().outPoint).toBe(5);
});
it("auto-enables loopEnabled when set to a non-null value", () => {
usePlayerStore.getState().setLoopEnabled(false);
usePlayerStore.getState().setInPoint(1.5);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
});
it("preserves loopEnabled when cleared with null", () => {
usePlayerStore.getState().setLoopEnabled(true);
usePlayerStore.getState().setInPoint(null);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
usePlayerStore.getState().setLoopEnabled(false);
usePlayerStore.getState().setInPoint(null);
expect(usePlayerStore.getState().loopEnabled).toBe(false);
});
});
describe("setOutPoint", () => {
it("updates outPoint", () => {
usePlayerStore.getState().setOutPoint(4.2);
expect(usePlayerStore.getState().outPoint).toBe(4.2);
});
it("clears outPoint when given null", () => {
usePlayerStore.getState().setOutPoint(4.2);
usePlayerStore.getState().setOutPoint(null);
expect(usePlayerStore.getState().outPoint).toBeNull();
});
it("rejects non-finite values", () => {
usePlayerStore.getState().setOutPoint(Number.POSITIVE_INFINITY);
expect(usePlayerStore.getState().outPoint).toBeNull();
});
it("nullifies inPoint when new outPoint is at or before existing inPoint", () => {
usePlayerStore.getState().setInPoint(5);
usePlayerStore.getState().setOutPoint(3);
expect(usePlayerStore.getState().inPoint).toBeNull();
expect(usePlayerStore.getState().outPoint).toBe(3);
});
it("preserves inPoint when new outPoint is after it", () => {
usePlayerStore.getState().setInPoint(2);
usePlayerStore.getState().setOutPoint(5);
expect(usePlayerStore.getState().inPoint).toBe(2);
});
it("auto-enables loopEnabled when set to a non-null value", () => {
usePlayerStore.getState().setLoopEnabled(false);
usePlayerStore.getState().setOutPoint(4.2);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
});
it("preserves loopEnabled when cleared with null", () => {
usePlayerStore.getState().setLoopEnabled(true);
usePlayerStore.getState().setOutPoint(null);
expect(usePlayerStore.getState().loopEnabled).toBe(true);
usePlayerStore.getState().setLoopEnabled(false);
usePlayerStore.getState().setOutPoint(null);
expect(usePlayerStore.getState().loopEnabled).toBe(false);
});
});
describe("setTimelineReady", () => {
it("updates timelineReady", () => {
usePlayerStore.getState().setTimelineReady(true);
expect(usePlayerStore.getState().timelineReady).toBe(true);
});
});
describe("setElements", () => {
it("sets the elements array", () => {
const elements: TimelineElement[] = [
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
{
id: "el-2",
tag: "video",
start: 2,
duration: 10,
track: 1,
src: "test.mp4",
},
];
usePlayerStore.getState().setElements(elements);
expect(usePlayerStore.getState().elements).toEqual(elements);
expect(usePlayerStore.getState().elements).toHaveLength(2);
});
it("replaces existing elements", () => {
usePlayerStore
.getState()
.setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
usePlayerStore
.getState()
.setElements([{ id: "el-3", tag: "span", start: 1, duration: 3, track: 0 }]);
const elements = usePlayerStore.getState().elements;
expect(elements).toHaveLength(1);
expect(elements[0].id).toBe("el-3");
});
});
describe("setSelectedElementId", () => {
it("selects an element", () => {
usePlayerStore.getState().setSelectedElementId("el-1");
expect(usePlayerStore.getState().selectedElementId).toBe("el-1");
});
it("clears selection with null", () => {
usePlayerStore.getState().setSelectedElementId("el-1");
usePlayerStore.getState().setSelectedElementId(null);
expect(usePlayerStore.getState().selectedElementId).toBeNull();
});
});
describe("updateElement", () => {
it("updates the start time of a specific element", () => {
usePlayerStore.getState().setElements([
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
{ id: "el-2", tag: "div", start: 5, duration: 5, track: 1 },
]);
usePlayerStore.getState().updateElement("el-1", { start: 3 });
const elements = usePlayerStore.getState().elements;
expect(elements[0].start).toBe(3);
expect(elements[1].start).toBe(5); // unchanged
});
it("does not modify elements when id is not found", () => {
const original: TimelineElement[] = [
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
];
usePlayerStore.getState().setElements(original);
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
expect(usePlayerStore.getState().elements[0].start).toBe(0);
});
it("prefers the stable element key when duplicate ids exist", () => {
usePlayerStore.getState().setElements([
{ id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
{ id: "headline", key: "b", tag: "div", start: 5, duration: 5, track: 1 },
]);
usePlayerStore.getState().updateElement("b", { start: 9 });
const elements = usePlayerStore.getState().elements;
expect(elements[0].start).toBe(0);
expect(elements[1].start).toBe(9);
});
});
describe("setZoomMode", () => {
it("changes zoom mode to manual", () => {
usePlayerStore.getState().setZoomMode("manual");
expect(usePlayerStore.getState().zoomMode).toBe("manual");
});
it("changes zoom mode back to fit", () => {
usePlayerStore.getState().setZoomMode("manual");
usePlayerStore.getState().setZoomMode("fit");
expect(usePlayerStore.getState().zoomMode).toBe("fit");
});
});
describe("setManualZoomPercent", () => {
it("updates the manual zoom percent", () => {
usePlayerStore.getState().setManualZoomPercent(200);
expect(usePlayerStore.getState().manualZoomPercent).toBe(200);
});
it("clamps to minimum of 10", () => {
usePlayerStore.getState().setManualZoomPercent(5);
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
});
it("clamps negative values to 10", () => {
usePlayerStore.getState().setManualZoomPercent(-50);
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
});
it("clamps to the maximum supported zoom percent", () => {
usePlayerStore.getState().setManualZoomPercent(5000);
expect(usePlayerStore.getState().manualZoomPercent).toBe(2000);
});
});
describe("reset", () => {
it("resets all state to defaults", () => {
// Mutate everything
const store = usePlayerStore.getState();
store.setIsPlaying(true);
store.setCurrentTime(42);
store.setDuration(120);
store.setTimelineReady(true);
store.setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
store.setSelectedElementId("el-1");
// Reset
usePlayerStore.getState().reset();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
});
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
const store = usePlayerStore.getState();
store.setPlaybackRate(2);
store.setAudioMuted(true);
store.setLoopEnabled(true);
store.setZoomMode("manual");
store.setManualZoomPercent(200);
usePlayerStore.getState().reset();
const state = usePlayerStore.getState();
// reset() only resets the fields explicitly listed in the reset function
expect(state.playbackRate).toBe(2);
expect(state.audioMuted).toBe(true);
expect(state.loopEnabled).toBe(true);
expect(state.zoomMode).toBe("manual");
expect(state.manualZoomPercent).toBe(200);
});
});
});
describe("liveTime", () => {
it("notifies subscribers with the current time", () => {
const listener = vi.fn();
const unsubscribe = liveTime.subscribe(listener);
liveTime.notify(5.5);
expect(listener).toHaveBeenCalledWith(5.5);
expect(listener).toHaveBeenCalledTimes(1);
liveTime.notify(10);
expect(listener).toHaveBeenCalledWith(10);
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it("supports multiple subscribers", () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const unsub1 = liveTime.subscribe(listener1);
const unsub2 = liveTime.subscribe(listener2);
liveTime.notify(3);
expect(listener1).toHaveBeenCalledWith(3);
expect(listener2).toHaveBeenCalledWith(3);
unsub1();
unsub2();
});
it("unsubscribe stops notifications", () => {
const listener = vi.fn();
const unsubscribe = liveTime.subscribe(listener);
liveTime.notify(1);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
liveTime.notify(2);
expect(listener).toHaveBeenCalledTimes(1); // not called again
});
it("unsubscribe returns true when listener existed", () => {
const listener = vi.fn();
const unsubscribe = liveTime.subscribe(listener);
// Set.delete returns boolean, our unsubscribe wraps it
const result = unsubscribe();
expect(result).toBe(true);
});
it("double unsubscribe returns false", () => {
const listener = vi.fn();
const unsubscribe = liveTime.subscribe(listener);
unsubscribe();
const result = unsubscribe();
expect(result).toBe(false);
});
});