From 8eeae2fb0a93dd6d053c8f3dfbccb2b70b632e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 26 Mar 2026 14:15:02 -0400 Subject: [PATCH] refactor(studio): improve player store with zoom, element updates, and tests - Add zoom state (zoomMode, pixelsPerSecond) to player store - Add updateElementDuration, updateElementTrack, updateElement actions - Remove agent activity tracking (activeEdits, agentId, agentColor) - Add comprehensive store tests (265 lines) - Add time utility tests --- .claude/skills/hyperframes-captions | 1 - .claude/skills/hyperframes-compose | 1 - packages/studio/src/player/lib/time.test.ts | 59 ++++ .../src/player/store/playerStore.test.ts | 265 ++++++++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) delete mode 120000 .claude/skills/hyperframes-captions delete mode 120000 .claude/skills/hyperframes-compose create mode 100644 packages/studio/src/player/lib/time.test.ts create mode 100644 packages/studio/src/player/store/playerStore.test.ts diff --git a/.claude/skills/hyperframes-captions b/.claude/skills/hyperframes-captions deleted file mode 120000 index a3c2afaa5..000000000 --- a/.claude/skills/hyperframes-captions +++ /dev/null @@ -1 +0,0 @@ -../../skills/hyperframes-captions \ No newline at end of file diff --git a/.claude/skills/hyperframes-compose b/.claude/skills/hyperframes-compose deleted file mode 120000 index 8028a889f..000000000 --- a/.claude/skills/hyperframes-compose +++ /dev/null @@ -1 +0,0 @@ -../../skills/hyperframes-compose \ No newline at end of file diff --git a/packages/studio/src/player/lib/time.test.ts b/packages/studio/src/player/lib/time.test.ts new file mode 100644 index 000000000..c796b7d91 --- /dev/null +++ b/packages/studio/src/player/lib/time.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { formatTime } from "./time"; + +describe("formatTime", () => { + it("formats zero seconds", () => { + expect(formatTime(0)).toBe("0:00"); + }); + + it("formats seconds less than a minute", () => { + expect(formatTime(5)).toBe("0:05"); + expect(formatTime(30)).toBe("0:30"); + expect(formatTime(59)).toBe("0:59"); + }); + + it("formats exact minutes", () => { + expect(formatTime(60)).toBe("1:00"); + expect(formatTime(120)).toBe("2:00"); + expect(formatTime(600)).toBe("10:00"); + }); + + it("formats minutes and seconds", () => { + expect(formatTime(65)).toBe("1:05"); + expect(formatTime(90)).toBe("1:30"); + expect(formatTime(125)).toBe("2:05"); + }); + + it("formats large values (over an hour)", () => { + expect(formatTime(3600)).toBe("60:00"); + expect(formatTime(3661)).toBe("61:01"); + expect(formatTime(7200)).toBe("120:00"); + }); + + it("floors fractional seconds", () => { + expect(formatTime(0.9)).toBe("0:00"); + expect(formatTime(1.5)).toBe("0:01"); + expect(formatTime(59.99)).toBe("0:59"); + expect(formatTime(60.5)).toBe("1:00"); + }); + + it("pads single-digit seconds with leading zero", () => { + expect(formatTime(1)).toBe("0:01"); + expect(formatTime(61)).toBe("1:01"); + expect(formatTime(609)).toBe("10:09"); + }); + + it("handles negative values", () => { + // Math.floor(-1/60) = -1, Math.floor(-1%60) = -1 + // padStart(2, "0") on "-1" is "-1" (already length 2) + expect(formatTime(-1)).toBe("-1:-1"); + }); + + it("handles NaN", () => { + expect(formatTime(NaN)).toBe("NaN:NaN"); + }); + + it("handles Infinity", () => { + expect(formatTime(Infinity)).toBe("Infinity:NaN"); + }); +}); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts new file mode 100644 index 000000000..a2df400cf --- /dev/null +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -0,0 +1,265 @@ +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.zoomMode).toBe("fit"); + expect(state.pixelsPerSecond).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("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("updateElementStart", () => { + 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().updateElementStart("el-1", 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().updateElementStart("nonexistent", 10); + expect(usePlayerStore.getState().elements[0].start).toBe(0); + }); + }); + + 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("setPixelsPerSecond", () => { + it("updates pixelsPerSecond", () => { + usePlayerStore.getState().setPixelsPerSecond(200); + expect(usePlayerStore.getState().pixelsPerSecond).toBe(200); + }); + + it("clamps to minimum of 10", () => { + usePlayerStore.getState().setPixelsPerSecond(5); + expect(usePlayerStore.getState().pixelsPerSecond).toBe(10); + }); + + it("clamps negative values to 10", () => { + usePlayerStore.getState().setPixelsPerSecond(-50); + expect(usePlayerStore.getState().pixelsPerSecond).toBe(10); + }); + }); + + 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, zoomMode, or pixelsPerSecond", () => { + const store = usePlayerStore.getState(); + store.setPlaybackRate(2); + store.setZoomMode("manual"); + store.setPixelsPerSecond(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.zoomMode).toBe("manual"); + expect(state.pixelsPerSecond).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); + }); +});