Files
hyperframes/packages/studio/src/components/sidebar/AssetCard.test.tsx
T
ukimsanov a33b3f35e1 fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit):
- asset reveal now clears any open preview overlay (stuck-overlay repro:
  preview on A, click already-added B — A stayed open over the reveal)
- duration readout rolls back on failed persist: captureDurationRollback
  snapshots store + live root data-duration before the optimistic sync and
  restores both in every move/resize/delete/group catch (golden's
  previousDuration pattern)
- asset preview opened during running playback dismisses immediately (the
  RAF loop bypasses the store, so the subscription alone never fired)
- persistTimelineBatchEdit resolves the target (findTagByTarget) before
  treating identical output as a no-op — a mistargeted member now throws
  like the single-element path instead of being silently dropped
- a post-mutation history-fold failure no longer suppresses the preview
  sync: fold errors are surfaced separately and the rewritten script still
  syncs (previously the preview kept stale GSAP positions with no recovery)
- timelineRevealScroll guards degenerate viewports (windowSize <= 0)
- CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches

Review 2 (single-source-of-truth pass):
- createTimelineElementFromManifestClip — the one manifest->element
  boundary — now carries authoredTrack and stackingContextId; expanded
  sub-comp children preserve both (authoredTrack in their OWN file's space)
- authoredTrackForLane scopes occupants to the dragged clip's sourceFile
  (a foreign file's authored values are a different coordinate space);
  nearest-same-file-lane offset fallback
- optimistic store updates mirror the persisted track into authoredTrack
  (and roll it back on failure), so consecutive drags before a reload
  resolve from fresh data
- spill sub-lanes: documented decision — dropping onto a spill lane is a
  legitimate same-track join (occupants share the authored track by
  construction); false 'never a lane-move target' docstring rewritten
- single-element fallback persists vertical-only moves (early return now
  requires neither start nor track changed; live DOM patch includes
  data-track-index)
- canonical contextKey helper for stacking-context normalization
- new pipeline test crosses the REAL factory boundary (sparse authored
  tracks -> factory -> expansion -> normalize -> drag commit -> persisted
  attribute), no injected fields
2026-07-13 16:48:52 -07:00

107 lines
3.8 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { AssetCard } from "./AssetCard";
import { AudioRow } from "./AudioRow";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
document.body.innerHTML = "";
usePlayerStore.getState().reset();
useAssetPreviewStore.getState().clearPreviewAsset();
vi.restoreAllMocks();
});
function mount(node: React.ReactElement): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(node);
});
return host;
}
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
}
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
function clickCard(host: HTMLElement): void {
const card = host.querySelector('[draggable="true"]');
if (!card) throw new Error("Expected a draggable card root");
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
act(() => {
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
});
}
describe("AssetCard click behavior", () => {
const cardProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
// or it stays stuck over the canvas while the timeline reveals the clip.
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
});
it("opens the preview overlay for a not-yet-added asset", () => {
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
});
});
describe("AudioRow click behavior", () => {
const rowProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
};
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
usePlayerStore
.getState()
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
});
it("opens the preview overlay for a not-yet-added audio asset", () => {
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
clickCard(host);
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
});
});