@hyperframes/studio 0.6.112 → 0.6.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BRjQ5a5_.js → hyperframes-player-C1qkW56w.js} +1 -1
- package/dist/assets/{index-C7VLzTP-.js → index-BR5v5xu2.js} +1 -1
- package/dist/assets/index-DApfviyW.js +269 -0
- package/dist/assets/index-DS7Kfzvb.css +1 -0
- package/dist/assets/{index-kfy9U4bN.js → index-DSLrl2tB.js} +24 -24
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +4 -0
- package/src/components/StudioRightPanel.tsx +98 -4
- package/src/components/panels/SlideshowPanel.test.ts +520 -0
- package/src/components/panels/SlideshowPanel.tsx +484 -0
- package/src/components/panels/SlideshowSubPanels.tsx +536 -0
- package/src/components/panels/slideshowPanelHelpers.ts +232 -0
- package/src/hooks/useGsapAnimationOps.ts +96 -0
- package/src/hooks/useSlideshowPersist.ts +68 -0
- package/src/utils/sdkCutover.ts +83 -2
- package/src/utils/setSlideshowManifest.test.ts +146 -0
- package/src/utils/setSlideshowManifest.ts +102 -0
- package/src/utils/studioHelpers.ts +2 -1
- package/dist/assets/index-C9b6Siu5.js +0 -370
- package/dist/assets/index-DP8pPIk2.css +0 -1
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
toggleMainLineSlide,
|
|
4
|
+
reorderMainLineSlide,
|
|
5
|
+
reorderBranchSlide,
|
|
6
|
+
setSlideNotes,
|
|
7
|
+
addFragment,
|
|
8
|
+
removeFragment,
|
|
9
|
+
createSequence,
|
|
10
|
+
renameSequence,
|
|
11
|
+
deleteSequence,
|
|
12
|
+
assignToBranch,
|
|
13
|
+
addHotspot,
|
|
14
|
+
removeHotspot,
|
|
15
|
+
safeParseManifest,
|
|
16
|
+
makeSlideshowNotesController,
|
|
17
|
+
} from "./SlideshowPanel";
|
|
18
|
+
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
19
|
+
|
|
20
|
+
// ── toggleMainLineSlide ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe("toggleMainLineSlide", () => {
|
|
23
|
+
it("adds a scene as a slide when absent", () => {
|
|
24
|
+
const m = toggleMainLineSlide({ slides: [] }, "a");
|
|
25
|
+
expect(m.slides).toEqual([{ sceneId: "a" }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("removes a scene when already present", () => {
|
|
29
|
+
const m = toggleMainLineSlide({ slides: [{ sceneId: "a" }] }, "a");
|
|
30
|
+
expect(m.slides).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not mutate the input manifest", () => {
|
|
34
|
+
const input: SlideshowManifest = { slides: [{ sceneId: "a" }] };
|
|
35
|
+
toggleMainLineSlide(input, "a");
|
|
36
|
+
expect(input.slides.length).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("leaves other slides intact when removing", () => {
|
|
40
|
+
const m = toggleMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "a");
|
|
41
|
+
expect(m.slides).toEqual([{ sceneId: "b" }]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── reorderMainLineSlide ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("reorderMainLineSlide", () => {
|
|
48
|
+
it("moves a slide up", () => {
|
|
49
|
+
const m = reorderMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "b", "up");
|
|
50
|
+
expect(m.slides.map((s) => s.sceneId)).toEqual(["b", "a"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("moves a slide down", () => {
|
|
54
|
+
const m = reorderMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "a", "down");
|
|
55
|
+
expect(m.slides.map((s) => s.sceneId)).toEqual(["b", "a"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns unchanged manifest when moving first slide up", () => {
|
|
59
|
+
const input: SlideshowManifest = { slides: [{ sceneId: "a" }, { sceneId: "b" }] };
|
|
60
|
+
const m = reorderMainLineSlide(input, "a", "up");
|
|
61
|
+
expect(m.slides.map((s) => s.sceneId)).toEqual(["a", "b"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns unchanged manifest for unknown sceneId", () => {
|
|
65
|
+
const input: SlideshowManifest = { slides: [{ sceneId: "a" }] };
|
|
66
|
+
const m = reorderMainLineSlide(input, "z", "up");
|
|
67
|
+
expect(m.slides).toEqual(input.slides);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── reorderBranchSlide ─────────────────────────────────────────────────────
|
|
72
|
+
describe("reorderBranchSlide", () => {
|
|
73
|
+
const base: SlideshowManifest = {
|
|
74
|
+
slides: [{ sceneId: "x" }],
|
|
75
|
+
slideSequences: [
|
|
76
|
+
{ id: "b1", label: "Branch", slides: [{ sceneId: "a" }, { sceneId: "b" }, { sceneId: "c" }] },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
it("moves a branch slide down within its sequence (main line untouched)", () => {
|
|
81
|
+
const m = reorderBranchSlide(base, "b1", "a", "down");
|
|
82
|
+
expect(m.slideSequences?.[0].slides.map((s) => s.sceneId)).toEqual(["b", "a", "c"]);
|
|
83
|
+
expect(m.slides).toEqual(base.slides);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is a no-op at the boundary and for an unknown branch/scene", () => {
|
|
87
|
+
expect(reorderBranchSlide(base, "b1", "a", "up").slideSequences?.[0].slides[0].sceneId).toBe(
|
|
88
|
+
"a",
|
|
89
|
+
);
|
|
90
|
+
expect(reorderBranchSlide(base, "nope", "a", "down")).toEqual(base);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── setSlideNotes ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe("setSlideNotes", () => {
|
|
97
|
+
it("updates notes on an existing slide", () => {
|
|
98
|
+
const m = setSlideNotes({ slides: [{ sceneId: "a" }] }, "a", "hello");
|
|
99
|
+
expect(m.slides[0]).toMatchObject({ sceneId: "a", notes: "hello" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("creates the slide entry if absent", () => {
|
|
103
|
+
const m = setSlideNotes({ slides: [] }, "a", "note");
|
|
104
|
+
expect(m.slides).toEqual([{ sceneId: "a", notes: "note" }]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── branch-scoped authoring (Finding #14) ──────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("branch-scoped editing (sequenceId)", () => {
|
|
111
|
+
const base: SlideshowManifest = {
|
|
112
|
+
slides: [{ sceneId: "a" }],
|
|
113
|
+
slideSequences: [{ id: "seq-1", label: "Branch", slides: [{ sceneId: "b" }] }],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("setSlideNotes edits the branch slide, leaving the main line untouched", () => {
|
|
117
|
+
const m = setSlideNotes(base, "b", "branch note", "seq-1");
|
|
118
|
+
expect(m.slideSequences?.[0]?.slides[0]).toMatchObject({ sceneId: "b", notes: "branch note" });
|
|
119
|
+
expect(m.slides[0]).toEqual({ sceneId: "a" }); // main line unchanged
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("setSlideNotes does NOT auto-add a slide to a branch when the scene is not assigned", () => {
|
|
123
|
+
const m = setSlideNotes(base, "z", "nope", "seq-1");
|
|
124
|
+
expect(m.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]);
|
|
125
|
+
expect(m.slides).toEqual([{ sceneId: "a" }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("addFragment edits the branch slide and does not auto-add when unassigned", () => {
|
|
129
|
+
const added = addFragment(base, "b", 1.5, "seq-1");
|
|
130
|
+
expect(added.slideSequences?.[0]?.slides[0]?.fragments).toEqual([1.5]);
|
|
131
|
+
const noAdd = addFragment(base, "z", 2.0, "seq-1");
|
|
132
|
+
expect(noAdd.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("addHotspot edits the branch slide and does not auto-add when unassigned", () => {
|
|
136
|
+
const hotspot = { id: "h1", label: "Why", target: "seq-2" };
|
|
137
|
+
const added = addHotspot(base, "b", hotspot, "seq-1");
|
|
138
|
+
expect(added.slideSequences?.[0]?.slides[0]?.hotspots).toEqual([hotspot]);
|
|
139
|
+
const noAdd = addHotspot(base, "z", hotspot, "seq-1");
|
|
140
|
+
expect(noAdd.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── addFragment ───────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe("addFragment", () => {
|
|
147
|
+
it("adds a fragment time to a slide", () => {
|
|
148
|
+
const m = addFragment({ slides: [{ sceneId: "a" }] }, "a", 1.5);
|
|
149
|
+
expect(m.slides[0]?.fragments).toEqual([1.5]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("deduplicates repeated fragment values", () => {
|
|
153
|
+
const m1 = addFragment({ slides: [{ sceneId: "a" }] }, "a", 1.5);
|
|
154
|
+
const m2 = addFragment(m1, "a", 1.5);
|
|
155
|
+
expect(m2.slides[0]?.fragments).toEqual([1.5]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps fragments sorted ascending", () => {
|
|
159
|
+
let m: SlideshowManifest = { slides: [{ sceneId: "a" }] };
|
|
160
|
+
m = addFragment(m, "a", 3.0);
|
|
161
|
+
m = addFragment(m, "a", 1.0);
|
|
162
|
+
m = addFragment(m, "a", 2.0);
|
|
163
|
+
expect(m.slides[0]?.fragments).toEqual([1.0, 2.0, 3.0]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("creates the slide entry if absent", () => {
|
|
167
|
+
const m = addFragment({ slides: [] }, "a", 0.5);
|
|
168
|
+
expect(m.slides[0]).toMatchObject({ sceneId: "a", fragments: [0.5] });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── removeFragment ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe("removeFragment", () => {
|
|
175
|
+
it("removes the specified fragment", () => {
|
|
176
|
+
const m = removeFragment({ slides: [{ sceneId: "a", fragments: [1.0, 2.0] }] }, "a", 1.0);
|
|
177
|
+
expect(m.slides[0]?.fragments).toEqual([2.0]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("no-ops when fragment not present", () => {
|
|
181
|
+
const m = removeFragment({ slides: [{ sceneId: "a", fragments: [1.0] }] }, "a", 9.0);
|
|
182
|
+
expect(m.slides[0]?.fragments).toEqual([1.0]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── createSequence ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe("createSequence", () => {
|
|
189
|
+
it("creates a new sequence", () => {
|
|
190
|
+
const m = createSequence({ slides: [] }, "seq-1", "Branch A");
|
|
191
|
+
expect(m.slideSequences).toEqual([{ id: "seq-1", label: "Branch A", slides: [] }]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("rejects duplicate ids", () => {
|
|
195
|
+
const m1 = createSequence({ slides: [] }, "seq-1", "Branch A");
|
|
196
|
+
const m2 = createSequence(m1, "seq-1", "Branch A duplicate");
|
|
197
|
+
expect((m2.slideSequences ?? []).length).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("preserves existing sequences", () => {
|
|
201
|
+
const m1 = createSequence({ slides: [] }, "seq-1", "A");
|
|
202
|
+
const m2 = createSequence(m1, "seq-2", "B");
|
|
203
|
+
expect((m2.slideSequences ?? []).length).toBe(2);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── renameSequence ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe("renameSequence", () => {
|
|
210
|
+
it("renames a sequence label", () => {
|
|
211
|
+
const m = renameSequence(
|
|
212
|
+
{ slides: [], slideSequences: [{ id: "seq-1", label: "Old", slides: [] }] },
|
|
213
|
+
"seq-1",
|
|
214
|
+
"New",
|
|
215
|
+
);
|
|
216
|
+
expect(m.slideSequences?.[0]?.label).toBe("New");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("no-ops on unknown id", () => {
|
|
220
|
+
const input: SlideshowManifest = {
|
|
221
|
+
slides: [],
|
|
222
|
+
slideSequences: [{ id: "seq-1", label: "A", slides: [] }],
|
|
223
|
+
};
|
|
224
|
+
const m = renameSequence(input, "unknown", "B");
|
|
225
|
+
expect(m.slideSequences?.[0]?.label).toBe("A");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── deleteSequence ─────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("deleteSequence", () => {
|
|
232
|
+
it("removes the sequence by id", () => {
|
|
233
|
+
const m = deleteSequence(
|
|
234
|
+
{ slides: [], slideSequences: [{ id: "seq-1", label: "A", slides: [] }] },
|
|
235
|
+
"seq-1",
|
|
236
|
+
);
|
|
237
|
+
expect(m.slideSequences).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("removes hotspots targeting the deleted sequence from main-line slides", () => {
|
|
241
|
+
const input: SlideshowManifest = {
|
|
242
|
+
slides: [
|
|
243
|
+
{
|
|
244
|
+
sceneId: "s1",
|
|
245
|
+
hotspots: [
|
|
246
|
+
{ id: "h1", label: "Go deep", target: "deep" },
|
|
247
|
+
{ id: "h2", label: "Other", target: "other-seq" },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
slideSequences: [
|
|
252
|
+
{ id: "deep", label: "Deep", slides: [] },
|
|
253
|
+
{ id: "other-seq", label: "Other", slides: [] },
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
const m = deleteSequence(input, "deep");
|
|
257
|
+
expect(m.slides[0]?.hotspots?.map((h) => h.id)).toEqual(["h2"]);
|
|
258
|
+
expect(m.slideSequences?.some((s) => s.id === "deep")).toBe(false);
|
|
259
|
+
// Verify no slide anywhere references 'deep'
|
|
260
|
+
const allHotspotTargets = [
|
|
261
|
+
...m.slides.flatMap((s) => (s.hotspots ?? []).map((h) => h.target)),
|
|
262
|
+
...(m.slideSequences ?? []).flatMap((seq) =>
|
|
263
|
+
seq.slides.flatMap((s) => (s.hotspots ?? []).map((h) => h.target)),
|
|
264
|
+
),
|
|
265
|
+
];
|
|
266
|
+
expect(allHotspotTargets).not.toContain("deep");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("removes hotspots targeting the deleted sequence from sequence slides", () => {
|
|
270
|
+
const input: SlideshowManifest = {
|
|
271
|
+
slides: [],
|
|
272
|
+
slideSequences: [
|
|
273
|
+
{ id: "deep", label: "Deep", slides: [] },
|
|
274
|
+
{
|
|
275
|
+
id: "other",
|
|
276
|
+
label: "Other",
|
|
277
|
+
slides: [
|
|
278
|
+
{
|
|
279
|
+
sceneId: "s2",
|
|
280
|
+
hotspots: [{ id: "h3", label: "To deep", target: "deep" }],
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
const m = deleteSequence(input, "deep");
|
|
287
|
+
const otherSeq = m.slideSequences?.find((s) => s.id === "other");
|
|
288
|
+
expect(otherSeq?.slides[0]?.hotspots).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── assignToBranch ─────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe("assignToBranch", () => {
|
|
295
|
+
it("assigns a scene to a branch", () => {
|
|
296
|
+
const m = assignToBranch(
|
|
297
|
+
{ slides: [], slideSequences: [{ id: "seq-1", label: "A", slides: [] }] },
|
|
298
|
+
"seq-1",
|
|
299
|
+
"s1",
|
|
300
|
+
true,
|
|
301
|
+
);
|
|
302
|
+
expect(m.slideSequences?.[0]?.slides).toEqual([{ sceneId: "s1" }]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("does not duplicate when assigning twice", () => {
|
|
306
|
+
let m: SlideshowManifest = {
|
|
307
|
+
slides: [],
|
|
308
|
+
slideSequences: [{ id: "seq-1", label: "A", slides: [] }],
|
|
309
|
+
};
|
|
310
|
+
m = assignToBranch(m, "seq-1", "s1", true);
|
|
311
|
+
m = assignToBranch(m, "seq-1", "s1", true);
|
|
312
|
+
expect(m.slideSequences?.[0]?.slides.length).toBe(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("removes a scene when assign=false", () => {
|
|
316
|
+
const m = assignToBranch(
|
|
317
|
+
{
|
|
318
|
+
slides: [],
|
|
319
|
+
slideSequences: [{ id: "seq-1", label: "A", slides: [{ sceneId: "s1" }] }],
|
|
320
|
+
},
|
|
321
|
+
"seq-1",
|
|
322
|
+
"s1",
|
|
323
|
+
false,
|
|
324
|
+
);
|
|
325
|
+
expect(m.slideSequences?.[0]?.slides).toEqual([]);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── addHotspot / removeHotspot ─────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe("addHotspot", () => {
|
|
332
|
+
it("adds a hotspot to a slide", () => {
|
|
333
|
+
const m = addHotspot({ slides: [{ sceneId: "a" }] }, "a", {
|
|
334
|
+
id: "h1",
|
|
335
|
+
label: "Go to B",
|
|
336
|
+
target: "seq-b",
|
|
337
|
+
});
|
|
338
|
+
expect(m.slides[0]?.hotspots).toEqual([{ id: "h1", label: "Go to B", target: "seq-b" }]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("does not duplicate hotspot ids", () => {
|
|
342
|
+
let m: SlideshowManifest = { slides: [{ sceneId: "a" }] };
|
|
343
|
+
m = addHotspot(m, "a", { id: "h1", label: "X", target: "seq-b" });
|
|
344
|
+
m = addHotspot(m, "a", { id: "h1", label: "Y", target: "seq-c" });
|
|
345
|
+
expect(m.slides[0]?.hotspots?.length).toBe(1);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("removeHotspot", () => {
|
|
350
|
+
it("removes a hotspot by id", () => {
|
|
351
|
+
const m = removeHotspot(
|
|
352
|
+
{
|
|
353
|
+
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "X", target: "seq-b" }] }],
|
|
354
|
+
},
|
|
355
|
+
"a",
|
|
356
|
+
"h1",
|
|
357
|
+
);
|
|
358
|
+
expect(m.slides[0]?.hotspots).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("no-ops for unknown hotspot id", () => {
|
|
362
|
+
const m = removeHotspot(
|
|
363
|
+
{
|
|
364
|
+
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "X", target: "seq-b" }] }],
|
|
365
|
+
},
|
|
366
|
+
"a",
|
|
367
|
+
"no-such-id",
|
|
368
|
+
);
|
|
369
|
+
expect(m.slides[0]?.hotspots?.length).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ── safeParseManifest ──────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
describe("safeParseManifest", () => {
|
|
376
|
+
it("parses a valid slideshow island", () => {
|
|
377
|
+
const manifest = { slides: [{ sceneId: "a" }] };
|
|
378
|
+
const island = `<script type="application/hyperframes-slideshow+json">${JSON.stringify(manifest)}</script>`;
|
|
379
|
+
const html = `<html><body>${island}</body></html>`;
|
|
380
|
+
const result = safeParseManifest(html);
|
|
381
|
+
expect(result.slides[0]?.sceneId).toBe("a");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("returns {slides:[]} for malformed JSON in the island", () => {
|
|
385
|
+
const html = `<html><body><script type="application/hyperframes-slideshow+json">NOT_JSON</script></body></html>`;
|
|
386
|
+
const result = safeParseManifest(html);
|
|
387
|
+
expect(result).toEqual({ slides: [] });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns {slides:[]} when no island is present", () => {
|
|
391
|
+
const result = safeParseManifest("<html><body></body></html>");
|
|
392
|
+
expect(result).toEqual({ slides: [] });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── makeSlideshowNotesController ──────────────────────────────────────────
|
|
397
|
+
//
|
|
398
|
+
// These tests prove the two stale-closure invariants without needing a DOM:
|
|
399
|
+
// (a) Notes typed in comp A always flush to comp A's callback, never comp B's.
|
|
400
|
+
// (b) A discrete action after typing does NOT drop the typed note.
|
|
401
|
+
|
|
402
|
+
describe("makeSlideshowNotesController", () => {
|
|
403
|
+
beforeEach(() => {
|
|
404
|
+
vi.useFakeTimers();
|
|
405
|
+
});
|
|
406
|
+
afterEach(() => {
|
|
407
|
+
vi.useRealTimers();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("(a) typing notes then switching composition flushes to the ORIGINAL callback", () => {
|
|
411
|
+
const ctrl = makeSlideshowNotesController();
|
|
412
|
+
const persistA = vi.fn().mockResolvedValue(undefined);
|
|
413
|
+
const persistB = vi.fn().mockResolvedValue(undefined);
|
|
414
|
+
|
|
415
|
+
const manifestA = { slides: [{ sceneId: "s1", notes: "typed in A" }] };
|
|
416
|
+
const manifestB = { slides: [{ sceneId: "s2" }] };
|
|
417
|
+
|
|
418
|
+
// User types a note in composition A — schedules debounce with persistA.
|
|
419
|
+
ctrl.schedule(manifestA, persistA, 450);
|
|
420
|
+
|
|
421
|
+
// Before the debounce fires, the composition switches to B.
|
|
422
|
+
// The panel calls flush() so the pending notes go to A's callback.
|
|
423
|
+
ctrl.flush();
|
|
424
|
+
|
|
425
|
+
// Now the panel re-schedules with B's manifest + callback.
|
|
426
|
+
ctrl.schedule(manifestB, persistB, 450);
|
|
427
|
+
|
|
428
|
+
// Advance time past the debounce delay.
|
|
429
|
+
vi.advanceTimersByTime(500);
|
|
430
|
+
|
|
431
|
+
// persistA must have been called with manifestA (the A-composition notes).
|
|
432
|
+
expect(persistA).toHaveBeenCalledOnce();
|
|
433
|
+
expect(persistA.mock.calls[0]?.[0]).toEqual(manifestA);
|
|
434
|
+
|
|
435
|
+
// persistB must have been called with manifestB (the B-composition timer).
|
|
436
|
+
expect(persistB).toHaveBeenCalledOnce();
|
|
437
|
+
expect(persistB.mock.calls[0]?.[0]).toEqual(manifestB);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("(a) flush after composition switch does NOT call the new composition's callback", () => {
|
|
441
|
+
const ctrl = makeSlideshowNotesController();
|
|
442
|
+
const persistA = vi.fn().mockResolvedValue(undefined);
|
|
443
|
+
const persistB = vi.fn().mockResolvedValue(undefined);
|
|
444
|
+
|
|
445
|
+
const manifestA = { slides: [{ sceneId: "s1", notes: "A notes" }] };
|
|
446
|
+
|
|
447
|
+
ctrl.schedule(manifestA, persistA, 450);
|
|
448
|
+
// Simulate comp switch: flush before B's manifest arrives.
|
|
449
|
+
ctrl.flush();
|
|
450
|
+
|
|
451
|
+
// B never schedules anything.
|
|
452
|
+
|
|
453
|
+
vi.advanceTimersByTime(1000);
|
|
454
|
+
|
|
455
|
+
expect(persistA).toHaveBeenCalledOnce();
|
|
456
|
+
expect(persistB).not.toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("(b) discrete action right after typing does NOT drop the note", () => {
|
|
460
|
+
const ctrl = makeSlideshowNotesController();
|
|
461
|
+
const persistNotes = vi.fn().mockResolvedValue(undefined);
|
|
462
|
+
|
|
463
|
+
const manifestWithNotes = { slides: [{ sceneId: "s1", notes: "hello" }] };
|
|
464
|
+
|
|
465
|
+
// User types "hello" — schedules debounce.
|
|
466
|
+
ctrl.schedule(manifestWithNotes, persistNotes, 450);
|
|
467
|
+
|
|
468
|
+
// Before debounce fires, user triggers a discrete action (e.g. mark fragment).
|
|
469
|
+
// The discrete manifest comes from the helper and does NOT include the note yet
|
|
470
|
+
// (it was computed from an older state snapshot).
|
|
471
|
+
const discreteManifest = { slides: [{ sceneId: "s1", fragments: [1.5] }] };
|
|
472
|
+
const merged = ctrl.mergeIntoDiscrete(discreteManifest);
|
|
473
|
+
|
|
474
|
+
// The merged manifest must include BOTH the fragment AND the note.
|
|
475
|
+
expect(merged.slides[0]).toMatchObject({ sceneId: "s1", notes: "hello", fragments: [1.5] });
|
|
476
|
+
|
|
477
|
+
// After mergeIntoDiscrete, pending is cleared — debounce no longer fires.
|
|
478
|
+
vi.advanceTimersByTime(500);
|
|
479
|
+
expect(persistNotes).not.toHaveBeenCalled();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("(b) notes from a different scene are not merged into an unrelated slide", () => {
|
|
483
|
+
const ctrl = makeSlideshowNotesController();
|
|
484
|
+
const persistNotes = vi.fn().mockResolvedValue(undefined);
|
|
485
|
+
|
|
486
|
+
// Pending notes are for scene s1.
|
|
487
|
+
const manifestWithNotes = { slides: [{ sceneId: "s1", notes: "s1 notes" }] };
|
|
488
|
+
ctrl.schedule(manifestWithNotes, persistNotes, 450);
|
|
489
|
+
|
|
490
|
+
// Discrete action affects scene s2 only.
|
|
491
|
+
const discreteManifest = { slides: [{ sceneId: "s2", fragments: [2.0] }] };
|
|
492
|
+
const merged = ctrl.mergeIntoDiscrete(discreteManifest);
|
|
493
|
+
|
|
494
|
+
// s2 slide should have no notes (pending notes belong to s1 which is not in discrete).
|
|
495
|
+
expect(merged.slides[0]).toMatchObject({ sceneId: "s2" });
|
|
496
|
+
expect(merged.slides[0]?.notes).toBeUndefined();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("flush is idempotent — second flush does nothing", () => {
|
|
500
|
+
const ctrl = makeSlideshowNotesController();
|
|
501
|
+
const persist = vi.fn().mockResolvedValue(undefined);
|
|
502
|
+
|
|
503
|
+
ctrl.schedule({ slides: [{ sceneId: "x" }] }, persist, 450);
|
|
504
|
+
ctrl.flush();
|
|
505
|
+
ctrl.flush();
|
|
506
|
+
|
|
507
|
+
expect(persist).toHaveBeenCalledOnce();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("cancel clears pending without calling persist", () => {
|
|
511
|
+
const ctrl = makeSlideshowNotesController();
|
|
512
|
+
const persist = vi.fn().mockResolvedValue(undefined);
|
|
513
|
+
|
|
514
|
+
ctrl.schedule({ slides: [] }, persist, 450);
|
|
515
|
+
ctrl.cancel();
|
|
516
|
+
|
|
517
|
+
vi.advanceTimersByTime(1000);
|
|
518
|
+
expect(persist).not.toHaveBeenCalled();
|
|
519
|
+
});
|
|
520
|
+
});
|