|
6 | 6 | createImplicitTimelineLayersFromDOM, |
7 | 7 | mergeTimelineElementsPreservingDowngrades, |
8 | 8 | } from "./timelineDOM"; |
| 9 | +import { isTimelineIgnoredElement } from "./timelineElementHelpers"; |
| 10 | +import { invalidateGroupInfoCache } from "./timelineGroupInfo"; |
9 | 11 | import type { TimelineElement } from "../store/playerStore"; |
10 | 12 |
|
11 | 13 | function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement { |
@@ -110,6 +112,92 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => { |
110 | 112 | }); |
111 | 113 | }); |
112 | 114 |
|
| 115 | +describe("group info cache", () => { |
| 116 | + const parseMember = (doc: Document) => |
| 117 | + createTimelineElementFromManifestClip({ |
| 118 | + clip: { |
| 119 | + id: "voice-1", |
| 120 | + label: "voice-1", |
| 121 | + kind: "element", |
| 122 | + tagName: "audio", |
| 123 | + start: 0, |
| 124 | + duration: 5, |
| 125 | + track: 0, |
| 126 | + compositionId: null, |
| 127 | + parentCompositionId: null, |
| 128 | + compositionSrc: null, |
| 129 | + assetUrl: null, |
| 130 | + }, |
| 131 | + fallbackIndex: 0, |
| 132 | + doc, |
| 133 | + hostEl: doc.getElementById("voice-1"), |
| 134 | + }); |
| 135 | + |
| 136 | + // Group edits are applied as LIVE patches so the preview iframe never |
| 137 | + // reloads, which means the document identity this cache is keyed on never |
| 138 | + // changes either. Without an explicit drop, a muted group could never be |
| 139 | + // unmuted: the header kept reading the cached `hidden: false` and re-wrote |
| 140 | + // `data-hidden` forever. |
| 141 | + it("re-reads group state after an invalidation", () => { |
| 142 | + const doc = makeDoc(` |
| 143 | + <div data-composition-id="root"> |
| 144 | + <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio> |
| 145 | + <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group> |
| 146 | + </div> |
| 147 | + `); |
| 148 | + |
| 149 | + expect(parseMember(doc).audioGroupHidden).toBe(false); |
| 150 | + |
| 151 | + doc.getElementById("voiceover")?.setAttribute("data-hidden", ""); |
| 152 | + expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan |
| 153 | + |
| 154 | + invalidateGroupInfoCache(doc); |
| 155 | + expect(parseMember(doc).audioGroupHidden).toBe(true); |
| 156 | + |
| 157 | + doc.getElementById("voiceover")?.removeAttribute("data-hidden"); |
| 158 | + invalidateGroupInfoCache(doc); |
| 159 | + expect(parseMember(doc).audioGroupHidden).toBe(false); |
| 160 | + }); |
| 161 | + |
| 162 | + // The explicit invalidator is a convenience, not the contract. A cache whose |
| 163 | + // only defence is "every writer must remember to call this" rots the first |
| 164 | + // time a writer does not know it exists — which is precisely what happened |
| 165 | + // with the FX rack, whose group writes go through the DOM editor rather than |
| 166 | + // the timeline's own writers. The scan carries the DOM revision it was taken |
| 167 | + // at, so a forgotten call costs a re-scan rather than a wrong answer. |
| 168 | + it("expires itself on a group edit nobody announced", async () => { |
| 169 | + const doc = makeDoc(` |
| 170 | + <div data-composition-id="root"> |
| 171 | + <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio> |
| 172 | + <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group> |
| 173 | + </div> |
| 174 | + `); |
| 175 | + |
| 176 | + expect(parseMember(doc).audioGroupHidden).toBe(false); |
| 177 | + |
| 178 | + // No invalidateGroupInfoCache call anywhere in this test. |
| 179 | + doc.getElementById("voiceover")?.setAttribute("data-hidden", ""); |
| 180 | + await new Promise((resolve) => setTimeout(resolve, 0)); // observer microtask |
| 181 | + |
| 182 | + expect(parseMember(doc).audioGroupHidden).toBe(true); |
| 183 | + }); |
| 184 | + |
| 185 | + it("notices a member joining the group, not just an attribute edit", async () => { |
| 186 | + const doc = makeDoc(` |
| 187 | + <div data-composition-id="root"> |
| 188 | + <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio> |
| 189 | + <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group> |
| 190 | + </div> |
| 191 | + `); |
| 192 | + expect(parseMember(doc).audioGroupLabel).toBe("Voices"); |
| 193 | + |
| 194 | + doc.getElementById("voiceover")?.setAttribute("data-label", "Narration"); |
| 195 | + await new Promise((resolve) => setTimeout(resolve, 0)); |
| 196 | + |
| 197 | + expect(parseMember(doc).audioGroupLabel).toBe("Narration"); |
| 198 | + }); |
| 199 | +}); |
| 200 | + |
113 | 201 | describe("parseTimelineFromDOM — canonical playback rate", () => { |
114 | 202 | it.each([ |
115 | 203 | ["10", 5], |
@@ -207,6 +295,33 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident |
207 | 295 | }); |
208 | 296 | }); |
209 | 297 |
|
| 298 | +// Caught by looking at the studio, not by reading: a grouped composition drew |
| 299 | +// "Voiceover • 0.0s – 12.0s" as a full-duration clip row directly above its own |
| 300 | +// group header. `<hf-audio-group>` is a mixer bus — no timing, drawn as a group |
| 301 | +// row by the group derivation — but it is still a body child with an id, so the |
| 302 | +// implicit-layer fallback happily gave it a track. Draggable and trimmable, and |
| 303 | +// writing timing onto a bus means nothing. |
| 304 | +describe("<hf-audio-group> is not a timeline layer", () => { |
| 305 | + it("gets no implicit row of its own", () => { |
| 306 | + const doc = makeDoc(` |
| 307 | + <div data-composition-id="root"> |
| 308 | + <audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio> |
| 309 | + <hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group> |
| 310 | + </div> |
| 311 | + `); |
| 312 | + |
| 313 | + const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []); |
| 314 | + |
| 315 | + expect(implicit.map((el) => el.domId)).not.toContain("voiceover"); |
| 316 | + }); |
| 317 | + |
| 318 | + it("is excluded by the shared ignore predicate", () => { |
| 319 | + const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`); |
| 320 | + expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true); |
| 321 | + expect(isTimelineIgnoredElement(doc.getElementById("panel") as Element)).toBe(false); |
| 322 | + }); |
| 323 | +}); |
| 324 | + |
210 | 325 | describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { |
211 | 326 | it("uses the runtime root paint scope for implicit siblings of manifest clips", () => { |
212 | 327 | const doc = makeDoc(` |
|
0 commit comments