// @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( "../../contexts/StudioContext", ); return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) }; }); afterEach(() => { document.body.innerHTML = ""; 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 the legacy // multi-field fallback — must not double-render the "Text" // heading (FlatGroup's own heading + TextSection's internal Section heading). 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 = baseElement(), propsOverride: Partial = {}, currentTime?: number, ) { vi.resetModules(); vi.doMock("./manualEditingAvailability", async () => { const actual = await vi.importActual( "./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(); }); 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( '[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( '[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 be suppressed when it's used as the flat fallback. expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull(); expect(host.querySelector('[data-panel-section="text"]')).toBeNull(); // Content from the legacy multi-field fallback must still 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("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(".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("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(".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("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(".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("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 — 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
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, ); });