mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
The flat inspector rendered its new Style/Grade groups AND the legacy ColorGradingSection/StyleSections components a second time below them, visibly doubling every control. Remove the now-redundant legacy render call sites (and their now-unused imports) from PropertyPanelFlat.tsx; those components stay intact for the legacy (flag-off) PropertyPanel.
840 lines
33 KiB
TypeScript
840 lines
33 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// PropertyPanel calls useStudioShellContext() unconditionally; supply the one
|
|
// field it reads (showToast) so the component can mount without the full shell.
|
|
vi.mock("../../contexts/StudioContext", async () => {
|
|
const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
|
|
"../../contexts/StudioContext",
|
|
);
|
|
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
// usePersistedPinnedGroups persists to localStorage; clear it so a pinned
|
|
// group from one test can't leak into the next (which would move a group out
|
|
// of the accordion and break an unrelated open-by-default assertion).
|
|
window.localStorage.clear();
|
|
vi.doUnmock("./manualEditingAvailability");
|
|
vi.resetModules();
|
|
});
|
|
|
|
function baseElement() {
|
|
return {
|
|
element: document.createElement("div"),
|
|
id: "mono-label",
|
|
selector: ".mono-label",
|
|
label: "Mono Label",
|
|
tagName: "div",
|
|
sourceFile: "index.html",
|
|
compositionPath: "index.html",
|
|
isCompositionHost: false,
|
|
isInsideLockedComposition: false,
|
|
boundingBox: { x: 0, y: -24, width: 257, height: 29 },
|
|
textContent: "PACKETS / FRAME",
|
|
dataAttributes: {},
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
textFields: [
|
|
{
|
|
key: "field-0",
|
|
label: "Text",
|
|
value: "PACKETS / FRAME",
|
|
tagName: "div",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self",
|
|
},
|
|
],
|
|
capabilities: {
|
|
canSelect: true,
|
|
canEditStyles: true,
|
|
canCrop: true,
|
|
canMove: true,
|
|
canResize: true,
|
|
canApplyManualOffset: true,
|
|
canApplyManualSize: true,
|
|
canApplyManualRotation: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Bug 1 fixture: no text fields at all, so isTextEditableSelection(element) is
|
|
// false — the Text FlatGroup must not render (not even empty/collapsed).
|
|
function nonTextElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "image-clip",
|
|
selector: "#image-clip",
|
|
label: "Image Clip",
|
|
tagName: "img",
|
|
textContent: "",
|
|
textFields: [],
|
|
};
|
|
}
|
|
|
|
// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to its own
|
|
// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
|
|
// must not double-render the "Text" heading (FlatGroup's own heading; this
|
|
// component never renders one of its own).
|
|
function multiFieldTextElement() {
|
|
const base = baseElement();
|
|
return {
|
|
...base,
|
|
textFields: [
|
|
base.textFields[0],
|
|
{
|
|
key: "field-1",
|
|
label: "Text",
|
|
value: "SECOND FIELD",
|
|
tagName: "div",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Style-only fixture: no text fields (Text group must not render), but
|
|
// canEditStyles stays true (inherited from baseElement()) so the Style group
|
|
// is gated in.
|
|
function styleOnlyElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "stat-card",
|
|
selector: ".stat-card",
|
|
label: "Stat Card",
|
|
textFields: [],
|
|
inlineStyles: { "background-color": "#0D0C09" },
|
|
};
|
|
}
|
|
|
|
// Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy
|
|
// StyleSections Flex `Section` AND the new flat Layout group's
|
|
// LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path.
|
|
// styles are read from computedStyles (PropertyPanel line ~113), so set it
|
|
// there.
|
|
function flexElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "flex-row",
|
|
selector: ".flex-row",
|
|
label: "Flex Row",
|
|
textFields: [],
|
|
computedStyles: { display: "flex" },
|
|
};
|
|
}
|
|
|
|
// Motion fixture (Plan 3b Task 4): an authored clip range (data-start present)
|
|
// makes resolveEditingSections turn on `sections.timing`, so the Motion group
|
|
// renders via its Timing gate even with no GSAP edit handlers wired.
|
|
function animatedElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "anim-clip",
|
|
selector: ".anim-clip",
|
|
label: "Anim Clip",
|
|
dataAttributes: { start: "0", duration: "4" },
|
|
};
|
|
}
|
|
|
|
// Inferred-timing fixture (whole-plan coherence fix): NO explicit data-start
|
|
// or data-duration — sections.timing must turn on via animationCount (fed
|
|
// from gsapAnimations.length), not an authored attribute, so both the Motion
|
|
// Timing row and the Layout keyframe gutter are forced to infer the range
|
|
// from the element's own GSAP tween instead of reading it off an attribute.
|
|
function inferredMotionElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "inferred-anim",
|
|
selector: "#inferred-anim",
|
|
label: "Inferred Anim",
|
|
};
|
|
}
|
|
|
|
// A single "to" tween running from t=2 to t=5 (position 2, duration 3), with
|
|
// keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's
|
|
// inference and the Layout "x" row's keyframe-seek gutter.
|
|
const INFERRED_TIMING_ANIMATION = {
|
|
id: "a1",
|
|
targetSelector: "#inferred-anim",
|
|
method: "to",
|
|
position: 2,
|
|
duration: 3,
|
|
properties: { x: 100 },
|
|
keyframes: {
|
|
format: "percentage",
|
|
keyframes: [
|
|
{ percentage: 0, properties: { x: 0 } },
|
|
{ percentage: 50, properties: { x: 50 } },
|
|
{ percentage: 100, properties: { x: 100 } },
|
|
],
|
|
},
|
|
} as never;
|
|
|
|
async function renderPanel(
|
|
flatEnabled: boolean,
|
|
elementOverride: ReturnType<typeof baseElement> = baseElement(),
|
|
propsOverride: Partial<PropertyPanelProps> = {},
|
|
currentTime?: number,
|
|
) {
|
|
vi.resetModules();
|
|
vi.doMock("./manualEditingAvailability", async () => {
|
|
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
|
"./manualEditingAvailability",
|
|
);
|
|
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
|
|
});
|
|
// Seed the playhead on the SAME store instance PropertyPanel.tsx will read via
|
|
// usePlayerStore (module-fresh since the resetModules() above) — must happen
|
|
// before PropertyPanel is imported/rendered so its initial render sees it.
|
|
if (currentTime !== undefined) {
|
|
const { usePlayerStore } = await import("../../player/store/playerStore");
|
|
usePlayerStore.getState().setCurrentTime(currentTime);
|
|
}
|
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
// Only the props the render path touches are supplied; the rest are unused at
|
|
// mount (handlers fire on interaction), so cast a minimal object to the full
|
|
// props shape rather than stubbing all ~15 required fields.
|
|
const props = {
|
|
element: elementOverride,
|
|
assets: [],
|
|
onSetStyle: vi.fn(),
|
|
onSetText: vi.fn(),
|
|
onSetAttributeLive: vi.fn(),
|
|
...propsOverride,
|
|
} as unknown as PropertyPanelProps;
|
|
act(() => {
|
|
root.render(<PropertyPanel {...props} />);
|
|
});
|
|
return { host, root };
|
|
}
|
|
|
|
// renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
|
|
// flag read); transforming the full section graph uncached can exceed the 5s
|
|
// default under heavy parallel full-suite load, so give these a wider margin.
|
|
const RENDER_TIMEOUT_MS = 20_000;
|
|
|
|
// Find the collapsed accordion row whose title matches and click it open.
|
|
function openFlatGroup(host: HTMLElement, title: string) {
|
|
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
|
|
el.textContent?.includes(title),
|
|
);
|
|
if (!row) throw new Error(`expected a collapsed ${title} row`);
|
|
act(() => row.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
}
|
|
|
|
const openGroupText = (host: HTMLElement) =>
|
|
host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
|
|
|
|
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
|
|
it(
|
|
"renders the legacy header, not the flat header",
|
|
async () => {
|
|
const { host, root } = await renderPanel(false);
|
|
expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
|
|
expect(host.textContent).toContain("Mono Label");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
|
it(
|
|
"renders the flat header, the Text group open by default, and the flat footer",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true);
|
|
expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
|
|
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
expect(host.textContent).toContain("Ask agent about this element");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"collapses the Text group on caret click and can reopen it",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true);
|
|
const collapseButton = host.querySelector<HTMLButtonElement>(
|
|
'[data-flat-group-open="true"] button[title="Collapse"]',
|
|
);
|
|
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
|
const collapsedRow = host.querySelector<HTMLButtonElement>(
|
|
'[data-flat-group-collapsed="true"]',
|
|
);
|
|
expect(collapsedRow).not.toBeNull();
|
|
act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"renders no Text group at all for a non-text element (bug 1)",
|
|
async () => {
|
|
// nonTextElement() inherits canEditStyles: true from baseElement(), so
|
|
// the Style group (Task 10) renders and opens by default here — the
|
|
// invariant under test is narrower than "no flat group at all": no
|
|
// group titled "Text" may appear, open or collapsed.
|
|
const { host, root } = await renderPanel(true, nonTextElement());
|
|
const openTitle = host.querySelector(
|
|
'[data-flat-group-open="true"] .text-panel-text-0',
|
|
)?.textContent;
|
|
const collapsedTitles = Array.from(
|
|
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
|
|
).map((el) => el.textContent);
|
|
expect(openTitle).not.toBe("Text");
|
|
expect(collapsedTitles).not.toContain("Text");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"renders exactly one Text heading for a multi-field text element (bug 2)",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, multiFieldTextElement());
|
|
// The FlatGroup's own "Text" heading is the only one that should exist —
|
|
// the legacy TextSection's internal Section heading (data-panel-section
|
|
// ="text") must never appear, since the flat multi-field path no longer
|
|
// delegates to that component at all.
|
|
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
|
|
// Content from the flat multi-field layer list must render.
|
|
expect(host.textContent).toContain("Text layers");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — Style group (flag on)", () => {
|
|
it(
|
|
"renders the Style group for a style-editable, non-text element",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, styleOnlyElement());
|
|
expect(host.textContent).toContain("Style");
|
|
expect(host.textContent).toContain("Fill");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"one-open accordion: opening Style closes Text",
|
|
async () => {
|
|
// baseElement() is text-editable and has capabilities.canEditStyles:
|
|
// true, so both the Text and Style groups render for it.
|
|
const { host, root } = await renderPanel(true);
|
|
const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
|
|
expect(textGroup()?.textContent).toContain("Text");
|
|
const styleCollapsedRow = Array.from(
|
|
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
).find((el) => el.textContent?.includes("Style"));
|
|
if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
|
|
act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(textGroup()?.textContent).not.toContain("Text");
|
|
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — Layout group (Plan 3a)", () => {
|
|
it(
|
|
"always renders the Layout group, and opening it closes whichever other group was open",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true);
|
|
// Text group is open by default for the base text-editable fixture.
|
|
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
|
|
|
|
const layoutCollapsedRow = Array.from(
|
|
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
).find((el) => el.textContent?.includes("Layout"));
|
|
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
|
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
|
|
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
expect(openGroup?.textContent).toContain("Layout");
|
|
expect(openGroup?.textContent).toContain("X");
|
|
expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, flexElement());
|
|
const layoutCollapsedRow = Array.from(
|
|
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
).find((el) => el.textContent?.includes("Layout"));
|
|
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
|
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
|
|
// The legacy StyleSections Flex `Section` (data-panel-section="flex") must
|
|
// NOT render on the flat path — the only two Flex renderers are the legacy
|
|
// Section and the flat LayoutFlexBlock, so its absence + the flat block's
|
|
// presence proves Flex renders exactly once (not twice, not zero).
|
|
expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
|
|
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
expect(openGroup?.textContent).toContain("Layout");
|
|
expect(openGroup?.textContent).toContain("Flex");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — Motion group (Plan 3b)", () => {
|
|
it(
|
|
"renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, animatedElement());
|
|
// Text is open by default for the text-editable fixture.
|
|
expect(openGroupText(host)).toContain("Text");
|
|
|
|
openFlatGroup(host, "Motion");
|
|
const openGroup = openGroupText(host);
|
|
expect(openGroup).toContain("Motion");
|
|
// FlatTimingRow (Start/End/Duration) renders inside the Motion group.
|
|
expect(openGroup).toContain("Start");
|
|
expect(openGroup).toContain("Duration");
|
|
// One-open accordion: opening Motion closed the Text group.
|
|
expect(openGroup).not.toContain("Text");
|
|
|
|
// Reverse direction: opening Layout closes Motion.
|
|
openFlatGroup(host, "Layout");
|
|
const openAfter = openGroupText(host);
|
|
expect(openAfter).toContain("Layout");
|
|
expect(openAfter).not.toContain("Motion");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"hides the effect list (showEffects off) when the GSAP edit handlers are absent",
|
|
async () => {
|
|
// STUDIO_GSAP_PANEL_ENABLED defaults on, but none of the five required
|
|
// edit handlers are supplied here, so the effect-list half of the
|
|
// double-gate stays closed — only the Timing row shows.
|
|
const { host, root } = await renderPanel(true, animatedElement());
|
|
openFlatGroup(host, "Motion");
|
|
const openGroup = openGroupText(host);
|
|
expect(openGroup).toContain("Motion");
|
|
expect(openGroup).toContain("Duration"); // Timing still shows
|
|
expect(openGroup).not.toContain("Add effect"); // effects gated off
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"shows the effect list (showEffects on) when the flag and all five handlers are present",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, animatedElement(), {
|
|
onUpdateGsapProperty: vi.fn(),
|
|
onUpdateGsapMeta: vi.fn(),
|
|
onDeleteGsapAnimation: vi.fn(),
|
|
onAddGsapProperty: vi.fn(),
|
|
onAddGsapAnimation: vi.fn(),
|
|
});
|
|
openFlatGroup(host, "Motion");
|
|
expect(openGroupText(host)).toContain("Add effect");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
// Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing
|
|
// row basis must agree on the same start/duration for an element that has
|
|
// animations but no explicit data-duration — before the fix, Layout fell back
|
|
// to a naive `duration ?? 1` while Motion correctly inferred the range from
|
|
// the tween (position 2, duration 3 -> start 2 / duration 3 / end 5).
|
|
describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => {
|
|
it(
|
|
"Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, inferredMotionElement(), {
|
|
gsapAnimations: [INFERRED_TIMING_ANIMATION],
|
|
});
|
|
openFlatGroup(host, "Motion");
|
|
const motionGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
if (!motionGroup) throw new Error("expected the Motion group to be open");
|
|
expect(motionGroup.textContent).toContain("Inferred");
|
|
const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
|
|
// FlatTimingRow renders Start, End, Duration in that order.
|
|
expect(inputs[0]?.value).toBe("2.00s");
|
|
expect(inputs[1]?.value).toBe("5.00s");
|
|
expect(inputs[2]?.value).toBe("3.00s");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)",
|
|
async () => {
|
|
const onSeekToTime = vi.fn();
|
|
// Seed the playhead at the clip's real start (t=2, the 0% keyframe's
|
|
// absolute time) — now that the follow-up fix also recomputes
|
|
// `currentPct` from the corrected elStart/elDuration basis, "current
|
|
// position is at the 0% keyframe" must be expressed as an actual t=2
|
|
// seek rather than relying on the store's untouched t=0 default (which,
|
|
// post-fix, resolves to a currentPct of -66.7% — well outside the 0%
|
|
// keyframe's tolerance window, and no longer "the case the coherence
|
|
// bug affected" that this test documents).
|
|
const { host, root } = await renderPanel(
|
|
true,
|
|
inferredMotionElement(),
|
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
2,
|
|
);
|
|
openFlatGroup(host, "Layout");
|
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
|
|
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
(el) => el.querySelector("span")?.textContent === "X",
|
|
);
|
|
if (!xRow) throw new Error("expected an X row");
|
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
// The diamond button always carries a `title`; the two plain arrow
|
|
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
|
|
// arrow is disabled (no earlier keyframe) and the next arrow seeks to
|
|
// the 50% keyframe — exactly the case the coherence bug affected.
|
|
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
(b) => !b.title && !b.disabled,
|
|
);
|
|
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
|
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
|
|
// Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
|
|
expect(onSeekToTime).toHaveBeenCalledWith(3.5);
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
// Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected
|
|
// WHERE a keyframe click seeks to, but `currentPct` — the value that drives
|
|
// KeyframeNavigation's diamond active/inactive state and prev/next arrow
|
|
// targeting — still used the OLD naive basis. For an inferred-duration
|
|
// element, seeking to a keyframe's actual absolute time no longer lit that
|
|
// keyframe's diamond as active. Prove the round-trip here: seek to the exact
|
|
// absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond
|
|
// renders "active" (title="Remove x keyframe"), not "inactive"/"ghost".
|
|
describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => {
|
|
it(
|
|
"lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)",
|
|
async () => {
|
|
const { host, root } = await renderPanel(
|
|
true,
|
|
inferredMotionElement(),
|
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION] },
|
|
3.5,
|
|
);
|
|
openFlatGroup(host, "Layout");
|
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
|
|
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
(el) => el.querySelector("span")?.textContent === "X",
|
|
);
|
|
if (!xRow) throw new Error("expected an X row");
|
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
|
|
if (!diamond) throw new Error("expected a keyframe diamond button");
|
|
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
|
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
|
// Before this fix, currentPct was computed against the naive
|
|
// elStart=0/elDuration=1 basis, so t=3.5 produced currentPct=350% —
|
|
// nowhere near the 50% keyframe within KeyframeNavigation's tolerance —
|
|
// and the diamond stayed "inactive" even though the playhead was
|
|
// exactly on that keyframe's real time.
|
|
expect(diamond.title).toBe("Remove x keyframe");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis",
|
|
async () => {
|
|
const onSeekToTime = vi.fn();
|
|
const { host, root } = await renderPanel(
|
|
true,
|
|
inferredMotionElement(),
|
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
3.5,
|
|
);
|
|
openFlatGroup(host, "Layout");
|
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
|
|
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
(el) => el.querySelector("span")?.textContent === "X",
|
|
);
|
|
if (!xRow) throw new Error("expected an X row");
|
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
|
|
const [prevArrow, , nextArrow] = buttons;
|
|
if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
|
|
// At the 50% keyframe (t=3.5), prev should target the 0% keyframe
|
|
// (absolute t=2) and next should target the 100% keyframe (absolute
|
|
// t=5) — both only resolvable once currentPct agrees with elStart=2/
|
|
// elDuration=3, the same basis the seek fix already uses.
|
|
expect(prevArrow.disabled).toBe(false);
|
|
act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onSeekToTime).toHaveBeenLastCalledWith(2);
|
|
|
|
expect(nextArrow.disabled).toBe(false);
|
|
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onSeekToTime).toHaveBeenLastCalledWith(5);
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
// Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections
|
|
// turns `media` on for (video/audio/img). Each carries no text fields (so the
|
|
// Text group never renders) and sets `element` to a real media node so the
|
|
// FlatMediaSection reads a live media element. `as never` casts around the
|
|
// element-type mismatch with baseElement()'s HTMLDivElement.
|
|
function videoElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "s1-bg",
|
|
selector: "#s1-bg",
|
|
label: "S1 Background",
|
|
tagName: "video",
|
|
textFields: [],
|
|
element: document.createElement("video"),
|
|
};
|
|
}
|
|
|
|
function imageElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "s1-img",
|
|
selector: "#s1-img",
|
|
label: "S1 Image",
|
|
tagName: "img",
|
|
textFields: [],
|
|
element: document.createElement("img"),
|
|
};
|
|
}
|
|
|
|
function audioElement() {
|
|
return {
|
|
...baseElement(),
|
|
id: "s1-audio",
|
|
selector: "#s1-audio",
|
|
label: "S1 Audio",
|
|
tagName: "audio",
|
|
textFields: [],
|
|
element: document.createElement("audio"),
|
|
};
|
|
}
|
|
|
|
// All FlatGroup titles currently mounted (open row + every collapsed row).
|
|
function flatGroupTitles(host: HTMLElement): string[] {
|
|
const open = Array.from(
|
|
host.querySelectorAll('[data-flat-group-open="true"] .text-panel-text-0'),
|
|
).map((el) => el.textContent ?? "");
|
|
const collapsed = Array.from(
|
|
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
|
|
).map((el) => el.textContent ?? "");
|
|
return [...open, ...collapsed];
|
|
}
|
|
|
|
describe("PropertyPanel — Grade group (flag on)", () => {
|
|
it(
|
|
"renders the Grade group with its accessory for a grade-editable (video) element",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, {
|
|
...baseElement(),
|
|
tagName: "video",
|
|
textFields: [],
|
|
});
|
|
const gradeCollapsedOrOpen =
|
|
host.querySelector('[data-flat-group-collapsed="true"]') ||
|
|
host.querySelector('[data-flat-group-open="true"]');
|
|
expect(host.textContent).toContain("Grade");
|
|
expect(gradeCollapsedOrOpen).not.toBeNull();
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"does not render the legacy Style/Grade Section duplicates for a style-and-grade-editable element",
|
|
async () => {
|
|
// A <video> with no text fields is both style-editable (inherited
|
|
// capabilities.canEditStyles: true) and grade-editable (tag === "video"),
|
|
// so both the flat Style and Grade groups render — the exact shape that
|
|
// used to also mount the legacy ColorGradingSection + StyleSections below
|
|
// them (the hybrid-duplication bug this task retires).
|
|
const { host, root } = await renderPanel(true, {
|
|
...baseElement(),
|
|
tagName: "video",
|
|
textFields: [],
|
|
});
|
|
expect(host.textContent).toContain("Style");
|
|
expect(host.textContent).toContain("Grade");
|
|
// The legacy `Section` primitive (propertyPanelStyleSections.tsx /
|
|
// propertyPanelColorGradingSection.tsx) renders a `<section
|
|
// data-panel-section="<slugified-title>">` for each of its sections. A
|
|
// bare textContent check can't tell a legacy Section title apart from a
|
|
// flat row label with the same word — "Fill" is both the legacy Fill
|
|
// `Section` title AND a row label inside FlatStyleSection, which is
|
|
// supposed to still be there — so assert on the legacy Section's actual
|
|
// DOM shape instead of a substring match.
|
|
for (const slug of [
|
|
"radius",
|
|
"stroke",
|
|
"effects",
|
|
"clip",
|
|
"transparency",
|
|
"fill",
|
|
"color-grading",
|
|
]) {
|
|
expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
|
|
}
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — Media group (Plan 4)", () => {
|
|
it(
|
|
"renders the flat Media group and not the legacy MediaSection, for a video element",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true, videoElement() as never);
|
|
// A Media FlatGroup exists (open or collapsed).
|
|
expect(flatGroupTitles(host)).toContain("Media");
|
|
// The legacy MediaSection renders its rows inside a `Section` whose
|
|
// data-panel-section slug is the media title ("video"/"image"/"audio").
|
|
// On the flat path it's fully replaced, so none of those may appear.
|
|
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
|
|
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
|
|
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)",
|
|
async () => {
|
|
// videoElement() has canEditStyles: true and no text fields, so Style is
|
|
// the default-open group; Layout and Media render collapsed alongside it.
|
|
const { host, root } = await renderPanel(true, videoElement() as never);
|
|
expect(openGroupText(host)).toContain("Style");
|
|
|
|
// Opening Media closes Style.
|
|
openFlatGroup(host, "Media");
|
|
const afterMedia = openGroupText(host);
|
|
expect(afterMedia).toContain("Media");
|
|
expect(afterMedia).not.toContain("Style");
|
|
|
|
// Reverse direction: opening Layout closes Media — same shared openGroupId.
|
|
openFlatGroup(host, "Layout");
|
|
const afterLayout = openGroupText(host);
|
|
expect(afterLayout).toContain("Layout");
|
|
expect(afterLayout).not.toContain("Media");
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element",
|
|
async () => {
|
|
for (const fixture of [videoElement, imageElement, audioElement]) {
|
|
const { host, root } = await renderPanel(true, fixture() as never);
|
|
expect(flatGroupTitles(host)).toContain("Media");
|
|
act(() => root.unmount());
|
|
}
|
|
|
|
// baseElement() is a plain <div> with text — sections.media is false, so
|
|
// no Media group (flat or legacy) may render.
|
|
const { host, root } = await renderPanel(true);
|
|
expect(flatGroupTitles(host)).not.toContain("Media");
|
|
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
|
|
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
|
|
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("PropertyPanel — pinning", () => {
|
|
it(
|
|
"renders a pinned group first, always open, above the PinnedZoneDivider",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true);
|
|
// Pin the Text group via its pin button.
|
|
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
|
|
if (!pinButton) throw new Error("expected a pin button on the open Text group");
|
|
act(() => pinButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
|
|
const pinnedRow = host.querySelector('[data-pinned-group="true"]');
|
|
expect(pinnedRow?.textContent).toContain("Text");
|
|
expect(pinnedRow?.textContent).toContain("Pinned");
|
|
|
|
// The divider must appear after the pinned zone.
|
|
const container = host.querySelector(".flex-1.overflow-y-auto");
|
|
const children = Array.from(container?.children ?? []);
|
|
const pinnedIndex = children.indexOf(pinnedRow as Element);
|
|
const dividerIndex = children.findIndex((el) => el.textContent?.includes("one open below"));
|
|
expect(pinnedIndex).toBeGreaterThanOrEqual(0);
|
|
expect(dividerIndex).toBeGreaterThan(pinnedIndex);
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"unpinning returns the group to its normal accordion stack position",
|
|
async () => {
|
|
const { host, root } = await renderPanel(true);
|
|
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
|
|
act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
const unpinButton = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
|
|
act(() => unpinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(host.querySelector('[data-pinned-group="true"]')).toBeNull();
|
|
act(() => root.unmount());
|
|
},
|
|
RENDER_TIMEOUT_MS,
|
|
);
|
|
});
|