@hyperframes/studio 0.8.78 → 0.8.80
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-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { DomEditProvider } from "../../contexts/DomEditContext";
|
|
6
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
7
|
+
import { jsonResponse, requestUrl } from "../../hooks/fetchStubTestUtils";
|
|
8
|
+
import { useTimelineEditing } from "../../hooks/useTimelineEditing";
|
|
9
|
+
import { useEditHistoryActions, type EditHistoryHandle } from "../../hooks/useEditHistoryActions";
|
|
10
|
+
import { usePreviewPersistence } from "../../hooks/usePreviewPersistence";
|
|
11
|
+
import { groupInfoFor } from "../lib/timelineGroupInfo";
|
|
12
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
13
|
+
import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation";
|
|
14
|
+
import { groupAutomationElement } from "./groupAutomationElement";
|
|
15
|
+
import { useAutomationLanes, type AutomationLaneBinding } from "./useAutomationLanes";
|
|
16
|
+
|
|
17
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
type CanEdit = NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
|
|
20
|
+
type DomEditValue = Parameters<typeof DomEditProvider>[0]["value"];
|
|
21
|
+
|
|
22
|
+
const SOURCE =
|
|
23
|
+
'<audio id="music" data-start="0" data-duration="12" data-track-index="0"></audio>' +
|
|
24
|
+
'<hf-audio-group id="hf-group"></hf-audio-group>';
|
|
25
|
+
const LOCKED = { blocked: true as const, reason: "Reserved by an agent" };
|
|
26
|
+
const NEXT = { version: 1 as const, lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }] };
|
|
27
|
+
|
|
28
|
+
const music: TimelineElement = {
|
|
29
|
+
id: "music",
|
|
30
|
+
key: "music",
|
|
31
|
+
domId: "music",
|
|
32
|
+
tag: "audio",
|
|
33
|
+
start: 0,
|
|
34
|
+
duration: 12,
|
|
35
|
+
track: 0,
|
|
36
|
+
sourceFile: "index.html",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const group = groupAutomationElement({ id: "hf-group", label: "Music", anchorKey: 0 }, 12);
|
|
40
|
+
const BOTH_LANES = [
|
|
41
|
+
{ lane: "clip", target: music, field: "automation" },
|
|
42
|
+
{ lane: "group", target: group, field: "audioGroupAutomation" },
|
|
43
|
+
] as const;
|
|
44
|
+
const curve = (v: number): HfAutomation => ({
|
|
45
|
+
version: 1,
|
|
46
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
document.body.innerHTML = "";
|
|
51
|
+
usePlayerStore.getState().reset();
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function mountLanes(target: TimelineElement, canEdit?: CanEdit, recording = false) {
|
|
56
|
+
let projectId = "p1";
|
|
57
|
+
let composition = "index.html";
|
|
58
|
+
// Every file but the open one, by `${project}/${path}`.
|
|
59
|
+
const files = new Map<string, string>();
|
|
60
|
+
const isRecordingRef = { current: recording };
|
|
61
|
+
let file = SOURCE;
|
|
62
|
+
let reads = 0;
|
|
63
|
+
let undoTo = SOURCE;
|
|
64
|
+
let undoPath: string | null = null;
|
|
65
|
+
let undoHeld = Promise.resolve();
|
|
66
|
+
const held = new Map<number, Promise<void>>();
|
|
67
|
+
const broken = new Set<number>();
|
|
68
|
+
vi.stubGlobal(
|
|
69
|
+
"fetch",
|
|
70
|
+
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
|
|
71
|
+
if (!requestUrl(input).includes("/files/")) return jsonResponse({});
|
|
72
|
+
const n = ++reads;
|
|
73
|
+
await held.get(n);
|
|
74
|
+
if (broken.has(n)) throw new Error("offline");
|
|
75
|
+
const [, owner, path] = /projects\/([^/]+)\/files\/([^?]+)/.exec(requestUrl(input)) ?? [];
|
|
76
|
+
const at = `${owner}/${decodeURIComponent(path ?? "")}`;
|
|
77
|
+
return jsonResponse({ content: at === `${projectId}/${composition}` ? file : files.get(at) });
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const put = (path: string, content: string) => {
|
|
81
|
+
if (path === composition) file = content;
|
|
82
|
+
else files.set(`${projectId}/${path}`, content);
|
|
83
|
+
};
|
|
84
|
+
const holdRead = (n: number) => {
|
|
85
|
+
let release = () => {};
|
|
86
|
+
held.set(n, new Promise<void>((resolve) => (release = resolve)));
|
|
87
|
+
return release;
|
|
88
|
+
};
|
|
89
|
+
const iframe = document.createElement("iframe");
|
|
90
|
+
document.body.append(iframe);
|
|
91
|
+
iframe.contentDocument!.body.innerHTML = SOURCE;
|
|
92
|
+
usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]);
|
|
93
|
+
const writeProjectFile = vi.fn(async (path: string, content: string) => put(path, content));
|
|
94
|
+
const refresh = vi.fn(async () => {});
|
|
95
|
+
const selection = { id: target.id };
|
|
96
|
+
const previewIframeRef = { current: iframe };
|
|
97
|
+
let binding: AutomationLaneBinding | null = null;
|
|
98
|
+
let history: ReturnType<typeof useEditHistoryActions> | null = null;
|
|
99
|
+
const editHistory = {
|
|
100
|
+
// In the file queue, like the real step, so saves started meanwhile queue behind it.
|
|
101
|
+
undo: async ({ serialize }: Parameters<EditHistoryHandle["undo"]>[0]) => {
|
|
102
|
+
const path = undoPath ?? composition;
|
|
103
|
+
const step = async () => {
|
|
104
|
+
await undoHeld;
|
|
105
|
+
const previous = path === composition ? file : (files.get(`${projectId}/${path}`) ?? "");
|
|
106
|
+
put(path, undoTo);
|
|
107
|
+
const restored = { [path]: { previous, restored: undoTo } };
|
|
108
|
+
return { ok: true, label: "Undo", paths: [path], files: restored };
|
|
109
|
+
};
|
|
110
|
+
return serialize ? serialize([path], step) : step();
|
|
111
|
+
},
|
|
112
|
+
redo: async () => ({ ok: false, reason: "empty" }),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function Probe() {
|
|
116
|
+
binding = useAutomationLanes().bind(target, true);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function Host() {
|
|
120
|
+
const editing = useTimelineEditing({
|
|
121
|
+
projectId,
|
|
122
|
+
activeCompPath: composition,
|
|
123
|
+
timelineElements: [music],
|
|
124
|
+
showToast: () => {},
|
|
125
|
+
writeProjectFile,
|
|
126
|
+
recordEdit: async () => {},
|
|
127
|
+
reloadPreview: () => {},
|
|
128
|
+
previewIframeRef,
|
|
129
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
130
|
+
uploadProjectFiles: async () => [],
|
|
131
|
+
canEdit,
|
|
132
|
+
isRecordingRef,
|
|
133
|
+
});
|
|
134
|
+
const persistence = usePreviewPersistence({
|
|
135
|
+
showToast: () => {},
|
|
136
|
+
readOptionalProjectFile: async () => "",
|
|
137
|
+
writeProjectFile,
|
|
138
|
+
recordEdit: async () => {},
|
|
139
|
+
previewIframeRef,
|
|
140
|
+
activeCompPathRef: { current: composition },
|
|
141
|
+
reloadPreview: () => {},
|
|
142
|
+
});
|
|
143
|
+
history = useEditHistoryActions({
|
|
144
|
+
editHistory,
|
|
145
|
+
readOptionalProjectFile: async () => "",
|
|
146
|
+
readProjectFile: async () => file,
|
|
147
|
+
writeProjectFile,
|
|
148
|
+
showToast: () => {},
|
|
149
|
+
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
|
|
150
|
+
waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
|
|
151
|
+
// As App.tsx wires it.
|
|
152
|
+
onAfterUndoRedo: (restore) => editing.restoreLiveLanes(restore),
|
|
153
|
+
activeCompPath: composition,
|
|
154
|
+
});
|
|
155
|
+
const domEdit = {
|
|
156
|
+
domEditSelectionRef: { current: selection },
|
|
157
|
+
refreshDomEditSelectionFromPreview: refresh,
|
|
158
|
+
} as unknown as DomEditValue;
|
|
159
|
+
return (
|
|
160
|
+
<DomEditProvider value={domEdit}>
|
|
161
|
+
<TimelineEditProvider
|
|
162
|
+
value={{
|
|
163
|
+
onSetElementAttributeLive: editing.setElementFxAttribute.setLive,
|
|
164
|
+
onSetElementAttributeQuiet: editing.setElementFxAttribute.setQuiet,
|
|
165
|
+
onSetAudioGroupAttributeLive: editing.setAudioGroupAttribute.setLive,
|
|
166
|
+
onSetAudioGroupAttributeQuiet: editing.setAudioGroupAttribute.setQuiet,
|
|
167
|
+
// As useTimelineEditCallbacks maps them.
|
|
168
|
+
onRevertElementAttributeLive: editing.setElementFxAttribute.revertLive,
|
|
169
|
+
onRevertAudioGroupAttributeLive: editing.setAudioGroupAttribute.revertLive,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<Probe />
|
|
173
|
+
</TimelineEditProvider>
|
|
174
|
+
</DomEditProvider>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const host = document.createElement("div");
|
|
179
|
+
document.body.append(host);
|
|
180
|
+
const root = createRoot(host);
|
|
181
|
+
act(() => root.render(<Host />));
|
|
182
|
+
const commit = async (next: HfAutomation = NEXT) => {
|
|
183
|
+
let outcome: unknown;
|
|
184
|
+
await act(async () => {
|
|
185
|
+
outcome = await binding!.onCommit(next);
|
|
186
|
+
});
|
|
187
|
+
return outcome;
|
|
188
|
+
};
|
|
189
|
+
const preview = (next: HfAutomation = NEXT) => act(() => binding!.onPreview(next));
|
|
190
|
+
const startCommit = (next: HfAutomation) => binding!.onCommit(next);
|
|
191
|
+
return {
|
|
192
|
+
commit,
|
|
193
|
+
startCommit,
|
|
194
|
+
preview,
|
|
195
|
+
writeProjectFile,
|
|
196
|
+
refresh,
|
|
197
|
+
selection,
|
|
198
|
+
iframe,
|
|
199
|
+
setFile: (next: string) => (file = next),
|
|
200
|
+
file: () => file,
|
|
201
|
+
reads: () => reads,
|
|
202
|
+
failRead: (n: number) => broken.add(n),
|
|
203
|
+
// A different project whose file, preview and store hold the same clip, unedited.
|
|
204
|
+
switchProject: (next: string, content = SOURCE) => {
|
|
205
|
+
files.set(`${projectId}/${composition}`, file);
|
|
206
|
+
projectId = next;
|
|
207
|
+
file = files.get(`${next}/${composition}`) ?? content;
|
|
208
|
+
iframe.contentDocument!.body.innerHTML = SOURCE;
|
|
209
|
+
usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]);
|
|
210
|
+
act(() => root.render(<Host />));
|
|
211
|
+
},
|
|
212
|
+
// Another composition of the same project, loaded into the preview with `stored` in the store.
|
|
213
|
+
switchComposition: (next: string, content: string, stored: TimelineElement) => {
|
|
214
|
+
files.set(`${projectId}/${composition}`, file);
|
|
215
|
+
composition = next;
|
|
216
|
+
file = files.get(`${projectId}/${next}`) ?? content;
|
|
217
|
+
iframe.contentDocument!.body.innerHTML = file;
|
|
218
|
+
usePlayerStore.getState().setElements([stored]);
|
|
219
|
+
act(() => root.render(<Host />));
|
|
220
|
+
},
|
|
221
|
+
fileAt: (path: string) => (path === composition ? file : files.get(`${projectId}/${path}`)),
|
|
222
|
+
put,
|
|
223
|
+
// Cmd+Z through the real history actions: the file goes back to `to`, the preview soft-restores.
|
|
224
|
+
undo: async (to: string, path?: string) => {
|
|
225
|
+
undoTo = to;
|
|
226
|
+
undoPath = path ?? null;
|
|
227
|
+
await act(async () => {
|
|
228
|
+
await history!.undo();
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
// An undo whose round trip waits for the returned release.
|
|
232
|
+
startUndo: (to: string) => {
|
|
233
|
+
let release = () => {};
|
|
234
|
+
undoTo = to;
|
|
235
|
+
undoHeld = new Promise<void>((resolve) => (release = resolve));
|
|
236
|
+
return { release, done: history!.undo() };
|
|
237
|
+
},
|
|
238
|
+
// A cancelled gesture's last live write, which puts its start back.
|
|
239
|
+
cancel: (next: HfAutomation) => act(() => binding!.onPreview(next, true)),
|
|
240
|
+
setRecording: (next: boolean) => (isRecordingRef.current = next),
|
|
241
|
+
holdRead,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
describe("useAutomationLanes saves report what happened", () => {
|
|
246
|
+
it("saves a clip's automation, and the lane and the panel read it back", async () => {
|
|
247
|
+
const { commit, writeProjectFile, refresh, selection } = mountLanes(music);
|
|
248
|
+
expect(await commit()).toEqual({ status: "saved" });
|
|
249
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain("data-automation=");
|
|
250
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toContain('"volume"');
|
|
251
|
+
expect(refresh).toHaveBeenCalledWith(selection);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses an automation edit on a locked clip and writes nothing", async () => {
|
|
255
|
+
const { commit, writeProjectFile } = mountLanes(music, () => LOCKED);
|
|
256
|
+
expect(await commit()).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
257
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
258
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("puts a dragged preview back when the lock refuses the release", async () => {
|
|
262
|
+
const { commit, iframe, preview } = mountLanes(music, () => LOCKED);
|
|
263
|
+
preview();
|
|
264
|
+
const node = iframe.contentDocument!.getElementById("music");
|
|
265
|
+
expect(node?.getAttribute("data-automation")).toContain('"volume"');
|
|
266
|
+
await commit();
|
|
267
|
+
expect(node?.hasAttribute("data-automation")).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("puts a dragged preview back when a recording refuses the release", async () => {
|
|
271
|
+
const { commit, iframe, preview, writeProjectFile } = mountLanes(music, undefined, true);
|
|
272
|
+
preview();
|
|
273
|
+
expect(await commit()).toEqual({
|
|
274
|
+
status: "refused",
|
|
275
|
+
reason: "Cannot edit timeline while recording",
|
|
276
|
+
});
|
|
277
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
278
|
+
false,
|
|
279
|
+
);
|
|
280
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("resolves an automation edit whose write fails as failed", async () => {
|
|
284
|
+
const { commit, writeProjectFile, iframe } = mountLanes(music);
|
|
285
|
+
writeProjectFile.mockRejectedValue(new Error("disk full"));
|
|
286
|
+
expect(await commit()).toEqual({
|
|
287
|
+
status: "failed",
|
|
288
|
+
reason: expect.stringContaining("disk full"),
|
|
289
|
+
});
|
|
290
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
291
|
+
false,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("refuses a group lane edit when a member is locked", async () => {
|
|
296
|
+
const { commit, writeProjectFile } = mountLanes(group, (el) =>
|
|
297
|
+
el.id === "music" ? LOCKED : true,
|
|
298
|
+
);
|
|
299
|
+
expect(await commit()).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
300
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("saves a group lane on the group and mirrors it to the members", async () => {
|
|
304
|
+
const { commit, writeProjectFile } = mountLanes(group);
|
|
305
|
+
expect(await commit()).toEqual({ status: "saved" });
|
|
306
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toMatch(
|
|
307
|
+
/<hf-audio-group id="hf-group" data-automation=/,
|
|
308
|
+
);
|
|
309
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toContain('"volume"');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps what an earlier save wrote when a later one fails while it is in flight", async () => {
|
|
313
|
+
const { startCommit, preview, writeProjectFile, iframe, setFile } = mountLanes(music);
|
|
314
|
+
const saved = serializeAutomation(NEXT);
|
|
315
|
+
let landFirst = () => {};
|
|
316
|
+
writeProjectFile
|
|
317
|
+
.mockImplementationOnce(
|
|
318
|
+
(_path, content) =>
|
|
319
|
+
new Promise<void>((resolve) => {
|
|
320
|
+
landFirst = () => {
|
|
321
|
+
setFile(content);
|
|
322
|
+
resolve();
|
|
323
|
+
};
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
326
|
+
.mockRejectedValueOnce(new Error("disk full"));
|
|
327
|
+
preview();
|
|
328
|
+
const first = startCommit(NEXT);
|
|
329
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
330
|
+
const later = {
|
|
331
|
+
version: 1 as const,
|
|
332
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }],
|
|
333
|
+
};
|
|
334
|
+
preview(later);
|
|
335
|
+
const second = startCommit(later);
|
|
336
|
+
landFirst();
|
|
337
|
+
let outcomes: unknown[] = [];
|
|
338
|
+
await act(async () => {
|
|
339
|
+
outcomes = await Promise.all([first, second]);
|
|
340
|
+
});
|
|
341
|
+
expect(outcomes).toMatchObject([{ status: "saved" }, { status: "failed" }]);
|
|
342
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
343
|
+
saved,
|
|
344
|
+
);
|
|
345
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(saved);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("never lets an older failed save's recovery land over a newer save", async () => {
|
|
349
|
+
const { commit, startCommit, preview, writeProjectFile, iframe, file, reads, holdRead } =
|
|
350
|
+
mountLanes(music);
|
|
351
|
+
const at = (v: number) => ({
|
|
352
|
+
version: 1 as const,
|
|
353
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
354
|
+
});
|
|
355
|
+
expect(await commit(at(0.5))).toEqual({ status: "saved" });
|
|
356
|
+
const releaseRecovery = holdRead(3);
|
|
357
|
+
writeProjectFile.mockRejectedValueOnce(new Error("offline"));
|
|
358
|
+
preview(at(0.9));
|
|
359
|
+
const older = startCommit(at(0.9));
|
|
360
|
+
await act(() => vi.waitFor(() => expect(reads()).toBe(3)));
|
|
361
|
+
preview(at(0.7));
|
|
362
|
+
const newer = startCommit(at(0.7));
|
|
363
|
+
releaseRecovery();
|
|
364
|
+
let outcomes: unknown[] = [];
|
|
365
|
+
await act(async () => {
|
|
366
|
+
outcomes = await Promise.all([older, newer]);
|
|
367
|
+
});
|
|
368
|
+
expect(outcomes).toMatchObject([{ status: "failed" }, { status: "saved" }]);
|
|
369
|
+
const landed = serializeAutomation(at(0.7));
|
|
370
|
+
const saved = new DOMParser().parseFromString(file(), "text/html");
|
|
371
|
+
expect(saved.getElementById("music")?.getAttribute("data-automation")).toBe(landed);
|
|
372
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
373
|
+
landed,
|
|
374
|
+
);
|
|
375
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("keeps a newer save when an older failed save cannot read the file back", async () => {
|
|
379
|
+
const { startCommit, preview, writeProjectFile, iframe, file, failRead } = mountLanes(music);
|
|
380
|
+
const at = (v: number) => ({
|
|
381
|
+
version: 1 as const,
|
|
382
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
383
|
+
});
|
|
384
|
+
let failOlder = () => {};
|
|
385
|
+
writeProjectFile.mockImplementationOnce(
|
|
386
|
+
() =>
|
|
387
|
+
new Promise<void>((_resolve, reject) => {
|
|
388
|
+
failOlder = () => reject(new Error("offline"));
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
preview(at(0.9));
|
|
392
|
+
const older = startCommit(at(0.9));
|
|
393
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
394
|
+
preview(at(0.7));
|
|
395
|
+
const newer = startCommit(at(0.7));
|
|
396
|
+
failRead(3);
|
|
397
|
+
failOlder();
|
|
398
|
+
await act(async () => {
|
|
399
|
+
await Promise.all([older, newer]);
|
|
400
|
+
});
|
|
401
|
+
const landed = serializeAutomation(at(0.7));
|
|
402
|
+
const saved = new DOMParser().parseFromString(file(), "text/html");
|
|
403
|
+
expect(saved.getElementById("music")?.getAttribute("data-automation")).toBe(landed);
|
|
404
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
405
|
+
landed,
|
|
406
|
+
);
|
|
407
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it.each([
|
|
411
|
+
{ lane: "clip", target: music, field: "automation", newer: "fails" },
|
|
412
|
+
{ lane: "clip", target: music, field: "automation", newer: "is refused" },
|
|
413
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", newer: "fails" },
|
|
414
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", newer: "is refused" },
|
|
415
|
+
] as const)(
|
|
416
|
+
"keeps a landed $lane save when a newer one $newer and cannot read the file back",
|
|
417
|
+
async ({ target, field, newer: how }) => {
|
|
418
|
+
const {
|
|
419
|
+
startCommit,
|
|
420
|
+
writeProjectFile,
|
|
421
|
+
iframe,
|
|
422
|
+
file,
|
|
423
|
+
setFile,
|
|
424
|
+
failRead,
|
|
425
|
+
reads,
|
|
426
|
+
setRecording,
|
|
427
|
+
} = mountLanes(target);
|
|
428
|
+
const at = (v: number) => ({
|
|
429
|
+
version: 1 as const,
|
|
430
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
431
|
+
});
|
|
432
|
+
let landOlder = () => {};
|
|
433
|
+
writeProjectFile
|
|
434
|
+
.mockImplementationOnce(
|
|
435
|
+
(_path, content) =>
|
|
436
|
+
new Promise<void>((resolve) => {
|
|
437
|
+
landOlder = () => {
|
|
438
|
+
setFile(content);
|
|
439
|
+
resolve();
|
|
440
|
+
};
|
|
441
|
+
}),
|
|
442
|
+
)
|
|
443
|
+
.mockRejectedValueOnce(new Error("offline"));
|
|
444
|
+
const older = startCommit(at(0.5));
|
|
445
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
446
|
+
if (how === "is refused") setRecording(true);
|
|
447
|
+
const newer = startCommit(at(0.9));
|
|
448
|
+
failRead(how === "fails" ? 3 : reads() + 1);
|
|
449
|
+
landOlder();
|
|
450
|
+
await act(async () => {
|
|
451
|
+
await Promise.all([older, newer]);
|
|
452
|
+
});
|
|
453
|
+
const want = serializeAutomation(at(0.5));
|
|
454
|
+
const saved = new DOMParser().parseFromString(file(), "text/html");
|
|
455
|
+
expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want);
|
|
456
|
+
expect(
|
|
457
|
+
iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"),
|
|
458
|
+
).toBe(want);
|
|
459
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want);
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
it.each([{ olderReadsBack: true }, { olderReadsBack: false }])(
|
|
464
|
+
"never settles on a value that was not saved when a refused save cannot read the file back (older reads back: $olderReadsBack)",
|
|
465
|
+
async ({ olderReadsBack }) => {
|
|
466
|
+
const { startCommit, preview, writeProjectFile, iframe, failRead, setRecording } =
|
|
467
|
+
mountLanes(music);
|
|
468
|
+
const at = (v: number) => ({
|
|
469
|
+
version: 1 as const,
|
|
470
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
471
|
+
});
|
|
472
|
+
let failOlder = () => {};
|
|
473
|
+
writeProjectFile.mockImplementationOnce(
|
|
474
|
+
() =>
|
|
475
|
+
new Promise<void>((_resolve, reject) => {
|
|
476
|
+
failOlder = () => reject(new Error("offline"));
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
preview(at(0.9));
|
|
480
|
+
const older = startCommit(at(0.9));
|
|
481
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
482
|
+
preview(at(0.7));
|
|
483
|
+
setRecording(true);
|
|
484
|
+
const refused = startCommit(at(0.7));
|
|
485
|
+
failRead(2);
|
|
486
|
+
if (!olderReadsBack) failRead(3);
|
|
487
|
+
failOlder();
|
|
488
|
+
await act(async () => {
|
|
489
|
+
await Promise.all([older, refused]);
|
|
490
|
+
});
|
|
491
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
492
|
+
false,
|
|
493
|
+
);
|
|
494
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
it.each([
|
|
499
|
+
{ lane: "clip", target: music, field: "automation", trigger: "offline" },
|
|
500
|
+
{ lane: "clip", target: music, field: "automation", trigger: "element gone" },
|
|
501
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", trigger: "offline" },
|
|
502
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", trigger: "element gone" },
|
|
503
|
+
] as const)(
|
|
504
|
+
"puts a $lane drag back when its first save fails ($trigger) and cannot read the file back",
|
|
505
|
+
async ({ target, field, trigger }) => {
|
|
506
|
+
const { commit, preview, writeProjectFile, iframe, setFile, failRead } = mountLanes(target);
|
|
507
|
+
const at = (v: number) => ({
|
|
508
|
+
version: 1 as const,
|
|
509
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
510
|
+
});
|
|
511
|
+
if (trigger === "offline") {
|
|
512
|
+
writeProjectFile.mockRejectedValueOnce(new Error("offline"));
|
|
513
|
+
failRead(2);
|
|
514
|
+
} else {
|
|
515
|
+
setFile("<html><body></body></html>");
|
|
516
|
+
}
|
|
517
|
+
preview(at(0.9));
|
|
518
|
+
expect(await commit(at(0.9))).toMatchObject({ status: "failed" });
|
|
519
|
+
expect(
|
|
520
|
+
iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"),
|
|
521
|
+
).toBe(false);
|
|
522
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined();
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
it.each([
|
|
527
|
+
{ lane: "clip", target: music, field: "automation", dragReadFails: false },
|
|
528
|
+
{ lane: "clip", target: music, field: "automation", dragReadFails: true },
|
|
529
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", dragReadFails: false },
|
|
530
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", dragReadFails: true },
|
|
531
|
+
] as const)(
|
|
532
|
+
"never verifies a $lane drag's before-value that an unsaved paste left behind (drag read fails: $dragReadFails)",
|
|
533
|
+
async ({ target, field, dragReadFails }) => {
|
|
534
|
+
const { startCommit, commit, preview, writeProjectFile, iframe, failRead } =
|
|
535
|
+
mountLanes(target);
|
|
536
|
+
const at = (v: number) => ({
|
|
537
|
+
version: 1 as const,
|
|
538
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
539
|
+
});
|
|
540
|
+
let failPaste = () => {};
|
|
541
|
+
writeProjectFile
|
|
542
|
+
.mockImplementationOnce(
|
|
543
|
+
() =>
|
|
544
|
+
new Promise<void>((_resolve, reject) => {
|
|
545
|
+
failPaste = () => reject(new Error("offline"));
|
|
546
|
+
}),
|
|
547
|
+
)
|
|
548
|
+
.mockRejectedValueOnce(new Error("offline"));
|
|
549
|
+
failRead(2);
|
|
550
|
+
failRead(4);
|
|
551
|
+
if (dragReadFails) failRead(3);
|
|
552
|
+
const paste = startCommit(at(0.5));
|
|
553
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
554
|
+
preview(at(0.9));
|
|
555
|
+
failPaste();
|
|
556
|
+
await act(async () => {
|
|
557
|
+
await paste;
|
|
558
|
+
});
|
|
559
|
+
expect(await commit(at(0.9))).toMatchObject({ status: "failed" });
|
|
560
|
+
expect(
|
|
561
|
+
iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"),
|
|
562
|
+
).toBe(false);
|
|
563
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined();
|
|
564
|
+
},
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
it("reverts a refused drag to the last saved value when its before-value was never saved", async () => {
|
|
568
|
+
let locked = false;
|
|
569
|
+
const { startCommit, commit, preview, writeProjectFile, iframe, failRead } = mountLanes(
|
|
570
|
+
music,
|
|
571
|
+
() => (locked ? LOCKED : true),
|
|
572
|
+
);
|
|
573
|
+
const at = (v: number) => ({
|
|
574
|
+
version: 1 as const,
|
|
575
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
576
|
+
});
|
|
577
|
+
expect(await commit(at(0.3))).toEqual({ status: "saved" });
|
|
578
|
+
let failPaste = () => {};
|
|
579
|
+
writeProjectFile.mockImplementationOnce(
|
|
580
|
+
() =>
|
|
581
|
+
new Promise<void>((_resolve, reject) => {
|
|
582
|
+
failPaste = () => reject(new Error("offline"));
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
failRead(3);
|
|
586
|
+
const paste = startCommit(at(0.5));
|
|
587
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(2)));
|
|
588
|
+
preview(at(0.9));
|
|
589
|
+
failPaste();
|
|
590
|
+
await act(async () => {
|
|
591
|
+
await paste;
|
|
592
|
+
});
|
|
593
|
+
locked = true;
|
|
594
|
+
expect(await commit(at(0.9))).toMatchObject({ status: "refused" });
|
|
595
|
+
const saved = serializeAutomation(at(0.3));
|
|
596
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
597
|
+
saved,
|
|
598
|
+
);
|
|
599
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(saved);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it.each([
|
|
603
|
+
{ lane: "clip", target: music, field: "automation" },
|
|
604
|
+
{ lane: "group", target: group, field: "audioGroupAutomation" },
|
|
605
|
+
] as const)(
|
|
606
|
+
"never settles another project's saved $lane value when a save cannot read the file back",
|
|
607
|
+
async ({ target, field }) => {
|
|
608
|
+
const { commit, preview, writeProjectFile, iframe, failRead, switchProject } =
|
|
609
|
+
mountLanes(target);
|
|
610
|
+
const at = (v: number) => ({
|
|
611
|
+
version: 1 as const,
|
|
612
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
613
|
+
});
|
|
614
|
+
preview(at(0.5));
|
|
615
|
+
expect(await commit(at(0.5))).toEqual({ status: "saved" });
|
|
616
|
+
switchProject("p2");
|
|
617
|
+
writeProjectFile.mockRejectedValueOnce(new Error("offline"));
|
|
618
|
+
failRead(3);
|
|
619
|
+
expect(await commit(at(0.9))).toMatchObject({ status: "failed" });
|
|
620
|
+
expect(
|
|
621
|
+
iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"),
|
|
622
|
+
).toBe(false);
|
|
623
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined();
|
|
624
|
+
},
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
it.each([{ held: "write" }, { held: "read" }] as const)(
|
|
628
|
+
"leaves a new project alone when a save made before the switch lands after it (held: $held)",
|
|
629
|
+
async ({ held }) => {
|
|
630
|
+
const {
|
|
631
|
+
startCommit,
|
|
632
|
+
preview,
|
|
633
|
+
writeProjectFile,
|
|
634
|
+
iframe,
|
|
635
|
+
setFile,
|
|
636
|
+
holdRead,
|
|
637
|
+
reads,
|
|
638
|
+
switchProject,
|
|
639
|
+
} = mountLanes(music);
|
|
640
|
+
const at = (v: number) => ({
|
|
641
|
+
version: 1 as const,
|
|
642
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
643
|
+
});
|
|
644
|
+
let land = () => {};
|
|
645
|
+
if (held === "write") {
|
|
646
|
+
writeProjectFile.mockImplementationOnce(
|
|
647
|
+
(_path, content) =>
|
|
648
|
+
new Promise<void>((resolve) => {
|
|
649
|
+
land = () => {
|
|
650
|
+
setFile(content);
|
|
651
|
+
resolve();
|
|
652
|
+
};
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
} else {
|
|
656
|
+
land = holdRead(1);
|
|
657
|
+
}
|
|
658
|
+
preview(at(0.5));
|
|
659
|
+
const saving = startCommit(at(0.5));
|
|
660
|
+
await act(() =>
|
|
661
|
+
vi.waitFor(() =>
|
|
662
|
+
held === "write"
|
|
663
|
+
? expect(writeProjectFile).toHaveBeenCalledTimes(1)
|
|
664
|
+
: expect(reads()).toBe(1),
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
switchProject("p2");
|
|
668
|
+
land();
|
|
669
|
+
await act(async () => {
|
|
670
|
+
await saving;
|
|
671
|
+
});
|
|
672
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
673
|
+
false,
|
|
674
|
+
);
|
|
675
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
676
|
+
},
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
it("reads a failed save back from its own project after a switch", async () => {
|
|
680
|
+
const { startCommit, writeProjectFile, iframe, failRead, reads, switchProject } =
|
|
681
|
+
mountLanes(music);
|
|
682
|
+
const at = (v: number) => ({
|
|
683
|
+
version: 1 as const,
|
|
684
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
685
|
+
});
|
|
686
|
+
let failFirst = () => {};
|
|
687
|
+
writeProjectFile
|
|
688
|
+
.mockImplementationOnce(
|
|
689
|
+
() =>
|
|
690
|
+
new Promise<void>((_resolve, reject) => {
|
|
691
|
+
failFirst = () => reject(new Error("offline"));
|
|
692
|
+
}),
|
|
693
|
+
)
|
|
694
|
+
.mockImplementationOnce(async () => {
|
|
695
|
+
failRead(reads() + 1);
|
|
696
|
+
throw new Error("offline");
|
|
697
|
+
});
|
|
698
|
+
const first = startCommit(at(0.5));
|
|
699
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
700
|
+
const doc = new DOMParser().parseFromString(SOURCE, "text/html");
|
|
701
|
+
doc.getElementById("music")!.setAttribute("data-automation", serializeAutomation(at(0.3)));
|
|
702
|
+
const other = doc.body.innerHTML;
|
|
703
|
+
switchProject("p2", other);
|
|
704
|
+
failFirst();
|
|
705
|
+
await act(async () => {
|
|
706
|
+
await first;
|
|
707
|
+
});
|
|
708
|
+
switchProject("p1");
|
|
709
|
+
await act(async () => {
|
|
710
|
+
await startCommit(at(0.9));
|
|
711
|
+
});
|
|
712
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
713
|
+
false,
|
|
714
|
+
);
|
|
715
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it.each([
|
|
719
|
+
{ lane: "clip", target: music, field: "automation", later: "fails" },
|
|
720
|
+
{ lane: "clip", target: music, field: "automation", later: "is refused" },
|
|
721
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", later: "fails" },
|
|
722
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", later: "is refused" },
|
|
723
|
+
] as const)(
|
|
724
|
+
"never brings back a $lane value an undo removed when a later save $later and cannot read the file back",
|
|
725
|
+
async ({ target, field, later }) => {
|
|
726
|
+
const {
|
|
727
|
+
commit,
|
|
728
|
+
startCommit,
|
|
729
|
+
preview,
|
|
730
|
+
writeProjectFile,
|
|
731
|
+
iframe,
|
|
732
|
+
setFile,
|
|
733
|
+
failRead,
|
|
734
|
+
reads,
|
|
735
|
+
setRecording,
|
|
736
|
+
} = mountLanes(target);
|
|
737
|
+
const at = (v: number) => ({
|
|
738
|
+
version: 1 as const,
|
|
739
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
740
|
+
});
|
|
741
|
+
preview(at(0.5));
|
|
742
|
+
expect(await commit(at(0.5))).toEqual({ status: "saved" });
|
|
743
|
+
// Undo: file, preview and store go back to the clip without automation.
|
|
744
|
+
setFile(SOURCE);
|
|
745
|
+
iframe.contentDocument!.body.innerHTML = SOURCE;
|
|
746
|
+
usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]);
|
|
747
|
+
if (later === "fails") {
|
|
748
|
+
writeProjectFile.mockImplementationOnce(async () => {
|
|
749
|
+
failRead(reads() + 1);
|
|
750
|
+
throw new Error("offline");
|
|
751
|
+
});
|
|
752
|
+
} else {
|
|
753
|
+
setRecording(true);
|
|
754
|
+
failRead(reads() + 1);
|
|
755
|
+
}
|
|
756
|
+
await act(async () => {
|
|
757
|
+
await startCommit(at(0.9));
|
|
758
|
+
});
|
|
759
|
+
expect(
|
|
760
|
+
iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"),
|
|
761
|
+
).toBe(false);
|
|
762
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined();
|
|
763
|
+
},
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
it("reads a failed save back from its own project while another save is in flight across a switch", async () => {
|
|
767
|
+
const { startCommit, writeProjectFile, iframe, failRead, holdRead, reads, switchProject } =
|
|
768
|
+
mountLanes(music);
|
|
769
|
+
const at = (v: number) => ({
|
|
770
|
+
version: 1 as const,
|
|
771
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
772
|
+
});
|
|
773
|
+
let failFirst = () => {};
|
|
774
|
+
writeProjectFile
|
|
775
|
+
.mockImplementationOnce(
|
|
776
|
+
() =>
|
|
777
|
+
new Promise<void>((_resolve, reject) => {
|
|
778
|
+
failFirst = () => reject(new Error("offline"));
|
|
779
|
+
}),
|
|
780
|
+
)
|
|
781
|
+
.mockRejectedValueOnce(new Error("offline"));
|
|
782
|
+
const first = startCommit(at(0.5));
|
|
783
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
784
|
+
const second = startCommit(at(0.7));
|
|
785
|
+
const doc = new DOMParser().parseFromString(SOURCE, "text/html");
|
|
786
|
+
doc.getElementById("music")!.setAttribute("data-automation", serializeAutomation(at(0.3)));
|
|
787
|
+
switchProject("p2", doc.body.innerHTML);
|
|
788
|
+
const releaseSecond = holdRead(4);
|
|
789
|
+
failRead(4);
|
|
790
|
+
failFirst();
|
|
791
|
+
await act(() => vi.waitFor(() => expect(reads()).toBe(4)));
|
|
792
|
+
switchProject("p1");
|
|
793
|
+
releaseSecond();
|
|
794
|
+
await act(async () => {
|
|
795
|
+
await Promise.all([first, second]);
|
|
796
|
+
});
|
|
797
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
798
|
+
false,
|
|
799
|
+
);
|
|
800
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("leaves the store to a newer pending save when an older one lands", async () => {
|
|
804
|
+
const { startCommit, writeProjectFile, setFile, holdRead } = mountLanes(music);
|
|
805
|
+
const at = (v: number) => ({
|
|
806
|
+
version: 1 as const,
|
|
807
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
808
|
+
});
|
|
809
|
+
let landOlder = () => {};
|
|
810
|
+
writeProjectFile.mockImplementationOnce(
|
|
811
|
+
(_path, content) =>
|
|
812
|
+
new Promise<void>((resolve) => {
|
|
813
|
+
landOlder = () => {
|
|
814
|
+
setFile(content);
|
|
815
|
+
resolve();
|
|
816
|
+
};
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
const older = startCommit(at(0.5));
|
|
820
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
821
|
+
const releaseNewer = holdRead(2);
|
|
822
|
+
const newer = startCommit(at(0.9));
|
|
823
|
+
landOlder();
|
|
824
|
+
await act(async () => {
|
|
825
|
+
await older;
|
|
826
|
+
});
|
|
827
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
828
|
+
releaseNewer();
|
|
829
|
+
await act(async () => {
|
|
830
|
+
await newer;
|
|
831
|
+
});
|
|
832
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(serializeAutomation(at(0.9)));
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("keeps a newer release's preview while an older save fails under it", async () => {
|
|
836
|
+
const { startCommit, preview, writeProjectFile, iframe, holdRead } = mountLanes(music);
|
|
837
|
+
const at = (v: number) => ({
|
|
838
|
+
version: 1 as const,
|
|
839
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
840
|
+
});
|
|
841
|
+
const shown = () =>
|
|
842
|
+
iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation");
|
|
843
|
+
let failOlder = () => {};
|
|
844
|
+
writeProjectFile.mockImplementationOnce(
|
|
845
|
+
() =>
|
|
846
|
+
new Promise<void>((_resolve, reject) => {
|
|
847
|
+
failOlder = () => reject(new Error("offline"));
|
|
848
|
+
}),
|
|
849
|
+
);
|
|
850
|
+
preview(at(0.9));
|
|
851
|
+
const older = startCommit(at(0.9));
|
|
852
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
853
|
+
const releaseNewer = holdRead(2);
|
|
854
|
+
preview(at(0.7));
|
|
855
|
+
const newer = startCommit(at(0.7));
|
|
856
|
+
failOlder();
|
|
857
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
|
858
|
+
expect(shown()).toBe(serializeAutomation(at(0.7)));
|
|
859
|
+
releaseNewer();
|
|
860
|
+
await act(async () => {
|
|
861
|
+
await Promise.all([older, newer]);
|
|
862
|
+
});
|
|
863
|
+
expect(shown()).toBe(serializeAutomation(at(0.7)));
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("keeps a newer live drag's preview when an older save's recovery lands", async () => {
|
|
867
|
+
const { startCommit, preview, writeProjectFile, iframe, reads, holdRead } = mountLanes(music);
|
|
868
|
+
const at = (v: number) => ({
|
|
869
|
+
version: 1 as const,
|
|
870
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
871
|
+
});
|
|
872
|
+
const releaseRecovery = holdRead(2);
|
|
873
|
+
writeProjectFile.mockRejectedValueOnce(new Error("offline"));
|
|
874
|
+
preview(at(0.9));
|
|
875
|
+
const older = startCommit(at(0.9));
|
|
876
|
+
await act(() => vi.waitFor(() => expect(reads()).toBe(2)));
|
|
877
|
+
preview(at(0.7));
|
|
878
|
+
releaseRecovery();
|
|
879
|
+
await act(async () => {
|
|
880
|
+
await older;
|
|
881
|
+
});
|
|
882
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
883
|
+
serializeAutomation(at(0.7)),
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("reverts a refused drag to the file after an older save failed under it", async () => {
|
|
888
|
+
let locked = false;
|
|
889
|
+
const { commit, startCommit, preview, writeProjectFile, iframe } = mountLanes(music, () =>
|
|
890
|
+
locked ? LOCKED : true,
|
|
891
|
+
);
|
|
892
|
+
const at = (v: number) => ({
|
|
893
|
+
version: 1 as const,
|
|
894
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
895
|
+
});
|
|
896
|
+
let failFirst = () => {};
|
|
897
|
+
writeProjectFile.mockImplementationOnce(
|
|
898
|
+
() =>
|
|
899
|
+
new Promise<void>((_resolve, reject) => {
|
|
900
|
+
failFirst = () => reject(new Error("offline"));
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
903
|
+
preview(at(0.9));
|
|
904
|
+
const older = startCommit(at(0.9));
|
|
905
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
906
|
+
preview(at(0.7));
|
|
907
|
+
failFirst();
|
|
908
|
+
await act(async () => {
|
|
909
|
+
await older;
|
|
910
|
+
});
|
|
911
|
+
locked = true;
|
|
912
|
+
expect(await commit(at(0.7))).toMatchObject({ status: "refused" });
|
|
913
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
914
|
+
false,
|
|
915
|
+
);
|
|
916
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("stores a save that lands after a newer live write was cancelled", async () => {
|
|
920
|
+
const { startCommit, preview, writeProjectFile, setFile } = mountLanes(music);
|
|
921
|
+
const at = (v: number) => ({
|
|
922
|
+
version: 1 as const,
|
|
923
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
924
|
+
});
|
|
925
|
+
let land = () => {};
|
|
926
|
+
writeProjectFile.mockImplementationOnce(
|
|
927
|
+
(_path, content) =>
|
|
928
|
+
new Promise<void>((resolve) => {
|
|
929
|
+
land = () => {
|
|
930
|
+
setFile(content);
|
|
931
|
+
resolve();
|
|
932
|
+
};
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
preview(at(0.5));
|
|
936
|
+
const saving = startCommit(at(0.5));
|
|
937
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
938
|
+
preview(at(0.9));
|
|
939
|
+
preview(at(0.5));
|
|
940
|
+
land();
|
|
941
|
+
await act(async () => {
|
|
942
|
+
await saving;
|
|
943
|
+
});
|
|
944
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(serializeAutomation(at(0.5)));
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it.each([
|
|
948
|
+
{ lane: "clip", target: music, field: "automation", pendingLands: true, expected: 0.7 },
|
|
949
|
+
{ lane: "clip", target: music, field: "automation", pendingLands: false, expected: null },
|
|
950
|
+
{
|
|
951
|
+
lane: "group",
|
|
952
|
+
target: group,
|
|
953
|
+
field: "audioGroupAutomation",
|
|
954
|
+
pendingLands: true,
|
|
955
|
+
expected: 0.7,
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
lane: "group",
|
|
959
|
+
target: group,
|
|
960
|
+
field: "audioGroupAutomation",
|
|
961
|
+
pendingLands: false,
|
|
962
|
+
expected: null,
|
|
963
|
+
},
|
|
964
|
+
] as const)(
|
|
965
|
+
"agrees with the file when a $lane drag is refused between an older failure and a pending save (lands: $pendingLands)",
|
|
966
|
+
async ({ target, field, pendingLands, expected }) => {
|
|
967
|
+
let locked = false;
|
|
968
|
+
const { commit, startCommit, preview, writeProjectFile, iframe, file, setFile } = mountLanes(
|
|
969
|
+
target,
|
|
970
|
+
() => (locked ? LOCKED : true),
|
|
971
|
+
);
|
|
972
|
+
const stored = () => usePlayerStore.getState().elements[0]?.[field] ?? null;
|
|
973
|
+
const shown = () =>
|
|
974
|
+
iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation") ?? null;
|
|
975
|
+
const at = (v: number) => ({
|
|
976
|
+
version: 1 as const,
|
|
977
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
978
|
+
});
|
|
979
|
+
let failOlder = () => {};
|
|
980
|
+
let finishPending = () => {};
|
|
981
|
+
writeProjectFile
|
|
982
|
+
.mockImplementationOnce(
|
|
983
|
+
() =>
|
|
984
|
+
new Promise<void>((_resolve, reject) => {
|
|
985
|
+
failOlder = () => reject(new Error("offline"));
|
|
986
|
+
}),
|
|
987
|
+
)
|
|
988
|
+
.mockImplementationOnce(
|
|
989
|
+
(_path, content) =>
|
|
990
|
+
new Promise<void>((resolve, reject) => {
|
|
991
|
+
finishPending = () => {
|
|
992
|
+
if (!pendingLands) return reject(new Error("offline"));
|
|
993
|
+
setFile(content);
|
|
994
|
+
resolve();
|
|
995
|
+
};
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
preview(at(0.9));
|
|
999
|
+
const older = startCommit(at(0.9));
|
|
1000
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
1001
|
+
preview(at(0.7));
|
|
1002
|
+
const pending = startCommit(at(0.7));
|
|
1003
|
+
preview(at(0.5));
|
|
1004
|
+
failOlder();
|
|
1005
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(2)));
|
|
1006
|
+
expect(shown()).toBe(serializeAutomation(at(0.5)));
|
|
1007
|
+
locked = true;
|
|
1008
|
+
expect(await commit(at(0.5))).toMatchObject({ status: "refused" });
|
|
1009
|
+
finishPending();
|
|
1010
|
+
await act(async () => {
|
|
1011
|
+
await Promise.all([older, pending]);
|
|
1012
|
+
});
|
|
1013
|
+
const want = expected === null ? null : serializeAutomation(at(expected));
|
|
1014
|
+
const saved = new DOMParser().parseFromString(file(), "text/html");
|
|
1015
|
+
expect(saved.getElementById(target.id)?.getAttribute("data-automation") ?? null).toBe(want);
|
|
1016
|
+
expect(shown()).toBe(want);
|
|
1017
|
+
expect(stored()).toBe(want);
|
|
1018
|
+
},
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
it.each([
|
|
1022
|
+
{ lane: "clip", target: music, field: "automation", newer: "saves", expected: 0.7 },
|
|
1023
|
+
{ lane: "clip", target: music, field: "automation", newer: "fails", expected: 0.9 },
|
|
1024
|
+
{ lane: "clip", target: music, field: "automation", newer: "is refused", expected: 0.9 },
|
|
1025
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", newer: "saves", expected: 0.7 },
|
|
1026
|
+
{ lane: "group", target: group, field: "audioGroupAutomation", newer: "fails", expected: 0.9 },
|
|
1027
|
+
{
|
|
1028
|
+
lane: "group",
|
|
1029
|
+
target: group,
|
|
1030
|
+
field: "audioGroupAutomation",
|
|
1031
|
+
newer: "is refused",
|
|
1032
|
+
expected: 0.9,
|
|
1033
|
+
},
|
|
1034
|
+
] as const)(
|
|
1035
|
+
"lands saves in the order they start when an older $lane save reads slowly and the newer one $newer",
|
|
1036
|
+
async ({ target, field, newer, expected }) => {
|
|
1037
|
+
const {
|
|
1038
|
+
startCommit,
|
|
1039
|
+
preview,
|
|
1040
|
+
writeProjectFile,
|
|
1041
|
+
iframe,
|
|
1042
|
+
file,
|
|
1043
|
+
holdRead,
|
|
1044
|
+
setFile,
|
|
1045
|
+
setRecording,
|
|
1046
|
+
} = mountLanes(target);
|
|
1047
|
+
const at = (v: number) => ({
|
|
1048
|
+
version: 1 as const,
|
|
1049
|
+
lanes: [{ target: "volume", points: [{ t: 0, v }] }],
|
|
1050
|
+
});
|
|
1051
|
+
writeProjectFile.mockImplementation(async (_path, content) => {
|
|
1052
|
+
if (newer === "fails" && content.includes("0.7")) throw new Error("offline");
|
|
1053
|
+
setFile(content);
|
|
1054
|
+
});
|
|
1055
|
+
const releaseOlder = holdRead(1);
|
|
1056
|
+
preview(at(0.9));
|
|
1057
|
+
const older = startCommit(at(0.9));
|
|
1058
|
+
if (newer === "is refused") setRecording(true);
|
|
1059
|
+
preview(at(0.7));
|
|
1060
|
+
const pending = startCommit(at(0.7));
|
|
1061
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 50)));
|
|
1062
|
+
releaseOlder();
|
|
1063
|
+
await act(async () => {
|
|
1064
|
+
await Promise.all([older, pending]);
|
|
1065
|
+
});
|
|
1066
|
+
const want = serializeAutomation(at(expected));
|
|
1067
|
+
const saved = new DOMParser().parseFromString(file(), "text/html");
|
|
1068
|
+
expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want);
|
|
1069
|
+
expect(
|
|
1070
|
+
iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"),
|
|
1071
|
+
).toBe(want);
|
|
1072
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want);
|
|
1073
|
+
},
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
it("settles on a queued save that lands after an earlier one fails", async () => {
|
|
1077
|
+
const { startCommit, preview, writeProjectFile, iframe, setFile } = mountLanes(music);
|
|
1078
|
+
let failFirst = () => {};
|
|
1079
|
+
writeProjectFile
|
|
1080
|
+
.mockImplementationOnce(
|
|
1081
|
+
() =>
|
|
1082
|
+
new Promise<void>((_resolve, reject) => {
|
|
1083
|
+
failFirst = () => reject(new Error("offline"));
|
|
1084
|
+
}),
|
|
1085
|
+
)
|
|
1086
|
+
.mockImplementationOnce(async (_path, content) => {
|
|
1087
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1088
|
+
setFile(content);
|
|
1089
|
+
});
|
|
1090
|
+
preview();
|
|
1091
|
+
const first = startCommit(NEXT);
|
|
1092
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
1093
|
+
const later = {
|
|
1094
|
+
version: 1 as const,
|
|
1095
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }],
|
|
1096
|
+
};
|
|
1097
|
+
preview(later);
|
|
1098
|
+
const second = startCommit(later);
|
|
1099
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
|
1100
|
+
failFirst();
|
|
1101
|
+
let outcomes: unknown[] = [];
|
|
1102
|
+
await act(async () => {
|
|
1103
|
+
outcomes = await Promise.all([first, second]);
|
|
1104
|
+
});
|
|
1105
|
+
expect(outcomes).toMatchObject([{ status: "failed" }, { status: "saved" }]);
|
|
1106
|
+
const landed = serializeAutomation(later);
|
|
1107
|
+
expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe(
|
|
1108
|
+
landed,
|
|
1109
|
+
);
|
|
1110
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("settles on the file when two overlapping saves both fail", async () => {
|
|
1114
|
+
const { startCommit, preview, writeProjectFile, iframe } = mountLanes(music);
|
|
1115
|
+
let failFirst = () => {};
|
|
1116
|
+
writeProjectFile
|
|
1117
|
+
.mockImplementationOnce(
|
|
1118
|
+
() =>
|
|
1119
|
+
new Promise<void>((_resolve, reject) => {
|
|
1120
|
+
failFirst = () => reject(new Error("offline"));
|
|
1121
|
+
}),
|
|
1122
|
+
)
|
|
1123
|
+
.mockRejectedValueOnce(new Error("offline"));
|
|
1124
|
+
preview();
|
|
1125
|
+
const first = startCommit(NEXT);
|
|
1126
|
+
await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1)));
|
|
1127
|
+
const later = {
|
|
1128
|
+
version: 1 as const,
|
|
1129
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }],
|
|
1130
|
+
};
|
|
1131
|
+
preview(later);
|
|
1132
|
+
const second = startCommit(later);
|
|
1133
|
+
failFirst();
|
|
1134
|
+
let outcomes: unknown[] = [];
|
|
1135
|
+
await act(async () => {
|
|
1136
|
+
outcomes = await Promise.all([first, second]);
|
|
1137
|
+
});
|
|
1138
|
+
expect(outcomes).toMatchObject([{ status: "failed" }, { status: "failed" }]);
|
|
1139
|
+
expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe(
|
|
1140
|
+
false,
|
|
1141
|
+
);
|
|
1142
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it("puts a group's dragged preview and mirror back when a recording refuses the release", async () => {
|
|
1146
|
+
const { commit, preview, iframe } = mountLanes(group, undefined, true);
|
|
1147
|
+
preview();
|
|
1148
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toContain('"volume"');
|
|
1149
|
+
expect(await commit()).toMatchObject({ status: "refused" });
|
|
1150
|
+
expect(
|
|
1151
|
+
iframe.contentDocument!.getElementById("hf-group")?.hasAttribute("data-automation"),
|
|
1152
|
+
).toBe(false);
|
|
1153
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toBeUndefined();
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it.each(
|
|
1157
|
+
BOTH_LANES.flatMap((lane) =>
|
|
1158
|
+
(["mid-drag", "with a live write left open"] as const).flatMap((undo) =>
|
|
1159
|
+
(["is refused", "goes offline"] as const).map((release) => ({ ...lane, undo, release })),
|
|
1160
|
+
),
|
|
1161
|
+
),
|
|
1162
|
+
)(
|
|
1163
|
+
"keeps the undone $lane value when an undo lands $undo and the next save $release with no read-back",
|
|
1164
|
+
async ({ target, field, undo: when, release }) => {
|
|
1165
|
+
const h = mountLanes(target);
|
|
1166
|
+
expect(await h.commit(curve(0.2))).toEqual({ status: "saved" });
|
|
1167
|
+
const base = h.file();
|
|
1168
|
+
expect(await h.commit(curve(0.5))).toEqual({ status: "saved" });
|
|
1169
|
+
h.preview(curve(0.9));
|
|
1170
|
+
if (when === "mid-drag") {
|
|
1171
|
+
await h.undo(base);
|
|
1172
|
+
h.preview(curve(0.9));
|
|
1173
|
+
} else {
|
|
1174
|
+
h.preview(curve(0.5));
|
|
1175
|
+
await h.undo(base);
|
|
1176
|
+
}
|
|
1177
|
+
if (release === "is refused") h.setRecording(true);
|
|
1178
|
+
else h.failRead(h.reads() + 2);
|
|
1179
|
+
h.failRead(h.reads() + 1);
|
|
1180
|
+
await act(async () => {
|
|
1181
|
+
await h.startCommit(curve(0.9));
|
|
1182
|
+
});
|
|
1183
|
+
const want = serializeAutomation(curve(0.2));
|
|
1184
|
+
const saved = new DOMParser().parseFromString(h.file(), "text/html");
|
|
1185
|
+
expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want);
|
|
1186
|
+
expect(
|
|
1187
|
+
h.iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"),
|
|
1188
|
+
).toBe(want);
|
|
1189
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want);
|
|
1190
|
+
},
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
it("clears a group's mirrored automation when an undo soft-restores the group without it", async () => {
|
|
1194
|
+
const { commit, iframe, undo, setFile } = mountLanes(group);
|
|
1195
|
+
const grouped = SOURCE.replace(
|
|
1196
|
+
'<audio id="music"',
|
|
1197
|
+
'<audio id="music" data-audio-group="hf-group"',
|
|
1198
|
+
);
|
|
1199
|
+
setFile(grouped);
|
|
1200
|
+
iframe.contentDocument!.body.innerHTML = grouped;
|
|
1201
|
+
expect(await commit(curve(0.5))).toEqual({ status: "saved" });
|
|
1202
|
+
// The timeline's parse warms the group cache on every render.
|
|
1203
|
+
expect(groupInfoFor(iframe.contentDocument, "hf-group").automation).toBe(
|
|
1204
|
+
serializeAutomation(curve(0.5)),
|
|
1205
|
+
);
|
|
1206
|
+
await undo(grouped);
|
|
1207
|
+
expect(
|
|
1208
|
+
iframe.contentDocument!.getElementById("hf-group")?.hasAttribute("data-automation"),
|
|
1209
|
+
).toBe(false);
|
|
1210
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toBeUndefined();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it.each(
|
|
1214
|
+
BOTH_LANES.flatMap((lane) => (["write", "read"] as const).map((held) => ({ ...lane, held }))),
|
|
1215
|
+
)(
|
|
1216
|
+
"leaves another composition alone when a $lane save made before switching to it lands after (held: $held)",
|
|
1217
|
+
async ({ target, field, held }) => {
|
|
1218
|
+
const h = mountLanes(target);
|
|
1219
|
+
const doc = new DOMParser().parseFromString(SOURCE, "text/html");
|
|
1220
|
+
const old = serializeAutomation(curve(0.2));
|
|
1221
|
+
for (const id of ["music", "hf-group"])
|
|
1222
|
+
doc.getElementById(id)!.setAttribute("data-automation", old);
|
|
1223
|
+
const other = doc.body.innerHTML;
|
|
1224
|
+
let land = () => {};
|
|
1225
|
+
if (held === "write") {
|
|
1226
|
+
h.writeProjectFile.mockImplementationOnce(
|
|
1227
|
+
(path, content) =>
|
|
1228
|
+
new Promise<void>((resolve) => {
|
|
1229
|
+
land = () => {
|
|
1230
|
+
h.put(path, content);
|
|
1231
|
+
resolve();
|
|
1232
|
+
};
|
|
1233
|
+
}),
|
|
1234
|
+
);
|
|
1235
|
+
} else {
|
|
1236
|
+
land = h.holdRead(1);
|
|
1237
|
+
}
|
|
1238
|
+
h.preview(curve(0.5));
|
|
1239
|
+
const saving = h.startCommit(curve(0.5));
|
|
1240
|
+
await act(() =>
|
|
1241
|
+
vi.waitFor(() =>
|
|
1242
|
+
held === "write"
|
|
1243
|
+
? expect(h.writeProjectFile).toHaveBeenCalledTimes(1)
|
|
1244
|
+
: expect(h.reads()).toBe(1),
|
|
1245
|
+
),
|
|
1246
|
+
);
|
|
1247
|
+
h.switchComposition("other.html", other, {
|
|
1248
|
+
...music,
|
|
1249
|
+
key: "other.html#music",
|
|
1250
|
+
sourceFile: "other.html",
|
|
1251
|
+
audioGroup: "hf-group",
|
|
1252
|
+
automation: old,
|
|
1253
|
+
audioGroupAutomation: old,
|
|
1254
|
+
});
|
|
1255
|
+
land();
|
|
1256
|
+
await act(async () => {
|
|
1257
|
+
await saving;
|
|
1258
|
+
});
|
|
1259
|
+
const inA = new DOMParser().parseFromString(h.fileAt("index.html") ?? "", "text/html");
|
|
1260
|
+
expect(inA.getElementById(target.id)?.getAttribute("data-automation")).toBe(
|
|
1261
|
+
serializeAutomation(curve(0.5)),
|
|
1262
|
+
);
|
|
1263
|
+
expect(h.file()).toBe(other);
|
|
1264
|
+
expect(
|
|
1265
|
+
h.iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"),
|
|
1266
|
+
).toBe(old);
|
|
1267
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBe(old);
|
|
1268
|
+
},
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
const lastFailing = (
|
|
1272
|
+
h: ReturnType<typeof mountLanes>,
|
|
1273
|
+
release: "is refused" | "goes offline",
|
|
1274
|
+
) => {
|
|
1275
|
+
if (release === "is refused") h.setRecording(true);
|
|
1276
|
+
else h.failRead(h.reads() + 2);
|
|
1277
|
+
h.failRead(h.reads() + 1);
|
|
1278
|
+
};
|
|
1279
|
+
const expectEverywhere = (
|
|
1280
|
+
h: ReturnType<typeof mountLanes>,
|
|
1281
|
+
target: TimelineElement,
|
|
1282
|
+
field: "automation" | "audioGroupAutomation",
|
|
1283
|
+
want: string,
|
|
1284
|
+
) => {
|
|
1285
|
+
const saved = new DOMParser().parseFromString(h.file(), "text/html");
|
|
1286
|
+
expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want);
|
|
1287
|
+
expect(
|
|
1288
|
+
h.iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"),
|
|
1289
|
+
).toBe(want);
|
|
1290
|
+
expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want);
|
|
1291
|
+
};
|
|
1292
|
+
const withEachRelease = BOTH_LANES.flatMap((lane) =>
|
|
1293
|
+
(["is refused", "goes offline"] as const).map((release) => ({ ...lane, release })),
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
it.each(withEachRelease)(
|
|
1297
|
+
"keeps a $lane value undone from another composition when the next save back in it $release with no read-back",
|
|
1298
|
+
async ({ target, field, release }) => {
|
|
1299
|
+
const h = mountLanes(target);
|
|
1300
|
+
expect(await h.commit(curve(0.2))).toEqual({ status: "saved" });
|
|
1301
|
+
const base = h.file();
|
|
1302
|
+
expect(await h.commit(curve(0.5))).toEqual({ status: "saved" });
|
|
1303
|
+
h.preview(curve(0.9));
|
|
1304
|
+
h.preview(curve(0.5));
|
|
1305
|
+
h.switchComposition("other.html", SOURCE, {
|
|
1306
|
+
...music,
|
|
1307
|
+
key: "other.html#music",
|
|
1308
|
+
sourceFile: "other.html",
|
|
1309
|
+
audioGroup: "hf-group",
|
|
1310
|
+
});
|
|
1311
|
+
await h.undo(base, "index.html");
|
|
1312
|
+
const undone = serializeAutomation(curve(0.2));
|
|
1313
|
+
h.switchComposition("index.html", SOURCE, {
|
|
1314
|
+
...music,
|
|
1315
|
+
audioGroup: "hf-group",
|
|
1316
|
+
automation: undone,
|
|
1317
|
+
audioGroupAutomation: undone,
|
|
1318
|
+
});
|
|
1319
|
+
lastFailing(h, release);
|
|
1320
|
+
await act(async () => {
|
|
1321
|
+
await h.startCommit(curve(0.9));
|
|
1322
|
+
});
|
|
1323
|
+
expectEverywhere(h, target, field, undone);
|
|
1324
|
+
},
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
it.each(withEachRelease)(
|
|
1328
|
+
"keeps the undone $lane value when a drag is released during the undo's round trip and its save $release with no read-back",
|
|
1329
|
+
async ({ target, field, release }) => {
|
|
1330
|
+
const h = mountLanes(target);
|
|
1331
|
+
expect(await h.commit(curve(0.2))).toEqual({ status: "saved" });
|
|
1332
|
+
const base = h.file();
|
|
1333
|
+
expect(await h.commit(curve(0.5))).toEqual({ status: "saved" });
|
|
1334
|
+
h.preview(curve(0.9));
|
|
1335
|
+
const undo = h.startUndo(base);
|
|
1336
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
|
1337
|
+
lastFailing(h, release);
|
|
1338
|
+
const releasing = h.startCommit(curve(0.9));
|
|
1339
|
+
undo.release();
|
|
1340
|
+
await act(async () => {
|
|
1341
|
+
await Promise.all([undo.done, releasing]);
|
|
1342
|
+
});
|
|
1343
|
+
expectEverywhere(h, target, field, serializeAutomation(curve(0.2)));
|
|
1344
|
+
},
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
it.each(BOTH_LANES)(
|
|
1348
|
+
"ends a cancelled $lane gesture, so a later outside write is what a failed save settles on",
|
|
1349
|
+
async ({ target, field }) => {
|
|
1350
|
+
const h = mountLanes(target);
|
|
1351
|
+
expect(await h.commit(curve(0.5))).toEqual({ status: "saved" });
|
|
1352
|
+
h.preview(curve(0.9));
|
|
1353
|
+
h.cancel(curve(0.5));
|
|
1354
|
+
// The property panel writes the lane's attribute without going through the lanes.
|
|
1355
|
+
const panel = serializeAutomation(curve(0.3));
|
|
1356
|
+
const doc = new DOMParser().parseFromString(h.file(), "text/html");
|
|
1357
|
+
doc.getElementById(target.id)!.setAttribute("data-automation", panel);
|
|
1358
|
+
h.setFile(doc.body.innerHTML);
|
|
1359
|
+
h.iframe.contentDocument!.getElementById(target.id)!.setAttribute("data-automation", panel);
|
|
1360
|
+
const [clip] = usePlayerStore.getState().elements;
|
|
1361
|
+
usePlayerStore.getState().setElements([{ ...clip!, [field]: panel }]);
|
|
1362
|
+
lastFailing(h, "is refused");
|
|
1363
|
+
await act(async () => {
|
|
1364
|
+
await h.startCommit(curve(0.9));
|
|
1365
|
+
});
|
|
1366
|
+
expectEverywhere(h, target, field, panel);
|
|
1367
|
+
},
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
it("reads a failed group save back from the file it started in after a trip to another composition", async () => {
|
|
1371
|
+
const h = mountLanes(group);
|
|
1372
|
+
expect(await h.commit(curve(0.2))).toEqual({ status: "saved" });
|
|
1373
|
+
const kept = serializeAutomation(curve(0.2));
|
|
1374
|
+
const stray = serializeAutomation(curve(0.9));
|
|
1375
|
+
// B declares the same group inside a sub-composition file, which holds 0.9.
|
|
1376
|
+
h.put("sub.html", `<hf-audio-group id="hf-group" data-automation='${stray}'></hf-audio-group>`);
|
|
1377
|
+
const other = `<div data-composition-id="sub" data-composition-file="sub.html">${SOURCE}</div>`;
|
|
1378
|
+
let failWrite = () => {};
|
|
1379
|
+
h.writeProjectFile.mockImplementationOnce(
|
|
1380
|
+
() =>
|
|
1381
|
+
new Promise<void>((_resolve, reject) => {
|
|
1382
|
+
failWrite = () => reject(new Error("offline"));
|
|
1383
|
+
}),
|
|
1384
|
+
);
|
|
1385
|
+
const saving = h.startCommit(curve(0.5));
|
|
1386
|
+
await act(() => vi.waitFor(() => expect(h.writeProjectFile).toHaveBeenCalledTimes(2)));
|
|
1387
|
+
h.switchComposition("other.html", other, {
|
|
1388
|
+
...music,
|
|
1389
|
+
key: "other.html#music",
|
|
1390
|
+
audioGroup: "hf-group",
|
|
1391
|
+
});
|
|
1392
|
+
const releaseReadBack = h.holdRead(h.reads() + 1);
|
|
1393
|
+
failWrite();
|
|
1394
|
+
await act(() => vi.waitFor(() => expect(h.reads()).toBe(3)));
|
|
1395
|
+
h.switchComposition("index.html", SOURCE, {
|
|
1396
|
+
...music,
|
|
1397
|
+
audioGroup: "hf-group",
|
|
1398
|
+
audioGroupAutomation: kept,
|
|
1399
|
+
});
|
|
1400
|
+
releaseReadBack();
|
|
1401
|
+
await act(async () => {
|
|
1402
|
+
await saving;
|
|
1403
|
+
});
|
|
1404
|
+
expectEverywhere(h, group, "audioGroupAutomation", kept);
|
|
1405
|
+
});
|
|
1406
|
+
});
|