Files
hyperframes/packages/studio/src/components/editor/PropertyPanel.test.tsx
T

269 lines
9.2 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 = "";
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 <TextSection> 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" },
};
}
async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
) {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
"./manualEditingAvailability",
);
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
});
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(),
} 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;
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 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,
);
});