// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS, HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS, HF_COLOR_GRADING_PALETTES, getHfColorGradingCapabilities, normalizeHfColorGrading, } from "@hyperframes/core/color-grading"; import { activeColorGradingEffectCount, FlatEffectsAccessory, FlatEffectsSection, } from "./propertyPanelFlatEffectsSection"; import { EFFECT_SPECS } from "./propertyPanelFlatEffectSpecs"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; }); function neutralGrading() { const grading = normalizeHfColorGrading("neutral"); if (!grading) throw new Error("expected neutral grading"); return grading; } function renderInto(node: React.ReactElement) { const host = document.body.appendChild(document.createElement("div")); const root = createRoot(host); act(() => root.render(node)); return { host, root }; } function sectionProps( grading: ReturnType, onCommitColorGrading: ReturnType = vi.fn(), ) { return { grading, previews: { status: "ready" as const, images: { pixelate: "data:image/png;base64,pixelate" }, width: 160, height: 90, }, presetPreviews: { status: "ready" as const, images: { "vhs-playback": "data:image/png;base64,vhs" }, width: 160, height: 90, }, onCommitColorGrading, onPreviewColorGrading: vi.fn(), onRequestEffectPreviews: vi.fn(), onRequestPresetPreviews: vi.fn(), }; } describe("FlatEffectsSection", () => { it("places missing composite treatments in their effect family without a Styles section", () => { const onCommit = vi.fn(); const props = sectionProps(neutralGrading(), onCommit); const { host, root } = renderInto(); expect(host.textContent).not.toContain("Styles"); expect(props.onRequestPresetPreviews).not.toHaveBeenCalled(); const retro = host.querySelector( '[data-flat-effect-group="Retro & Glitch"]', ); act(() => retro?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(props.onRequestPresetPreviews).toHaveBeenCalledTimes(1); expect(host.querySelector('[data-flat-effect-preset-preview="vhs-playback"]')).not.toBeNull(); const vhs = host.querySelector('[data-flat-effect-preset="vhs-playback"]'); act(() => vhs?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true }))); expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[0]).toMatchObject({ preset: "vhs-playback", effects: { tapeDamage: 0.82, scanlineCount: 0.17 }, }); expect(onCommit).not.toHaveBeenCalled(); act(() => vhs?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls[0][0].preset).toBe("vhs-playback"); const print = host.querySelector('[data-flat-effect-group="Print"]'); act(() => print?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(host.querySelector('[data-flat-effect-preset="editorial-halftone"]')).not.toBeNull(); expect(host.querySelector('[data-flat-effect-preset="two-ink-print"]')).not.toBeNull(); act(() => root.unmount()); }); it("offers every shader master once in four intentional families", () => { const { host, root } = renderInto(); const renderedKeys: Array = []; for (const label of ["Essentials", "Retro & Glitch", "Print", "Art"]) { const tab = host.querySelector(`[data-flat-effect-group="${label}"]`); expect(tab).not.toBeNull(); act(() => tab?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); renderedKeys.push( ...Array.from(host.querySelectorAll("[data-flat-effect-option]"), (element) => element.getAttribute("data-flat-effect-option"), ), ); } expect(renderedKeys.sort()).toEqual([...HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS].sort()); const capabilities = new Map( getHfColorGradingCapabilities().effects.map((effect) => [effect.key, effect]), ); for (const effect of EFFECT_SPECS) { expect(Boolean(effect.palette)).toBe(capabilities.get(effect.key)?.supportsPalette); } expect(host.querySelectorAll('[data-flat-slider-track="true"]')).toHaveLength(0); act(() => root.unmount()); }); it("requests exact cards and auditions a chosen default without persisting", () => { const props = sectionProps(neutralGrading()); const { host, root } = renderInto(); expect(props.onRequestEffectPreviews).toHaveBeenCalledTimes(1); expect(props.onRequestEffectPreviews).toHaveBeenCalledWith(["blur", "pixelate", "bloom"]); expect(host.querySelector('[data-flat-effect-preview="pixelate"]')).not.toBeNull(); const pixelate = host.querySelector('[data-flat-effect-option="pixelate"]'); act(() => pixelate?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true }))); expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[0].effects.pixelate).toBe( HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate, ); expect(props.onPreviewColorGrading.mock.calls.at(-1)?.[1]).toEqual({ animatedPreview: { kind: "effects", id: "pixelate" }, }); expect(props.onCommitColorGrading).not.toHaveBeenCalled(); act(() => pixelate?.dispatchEvent(new MouseEvent("pointerout", { bubbles: true }))); expect(props.onPreviewColorGrading).toHaveBeenLastCalledWith(null); act(() => root.unmount()); }); it("applies a useful canonical default when an effect is chosen", () => { const onCommit = vi.fn(); const grading = { ...neutralGrading(), intensity: 0 }; const { host, root } = renderInto(); const pixelate = host.querySelector('[data-flat-effect-option="pixelate"]'); act(() => pixelate?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit).toHaveBeenCalledTimes(1); expect(onCommit.mock.calls[0][0].effects.pixelate).toBe( HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate, ); expect(onCommit.mock.calls[0][0].intensity).toBe(0); act(() => root.unmount()); }); it("uses a complete treatment card as a fresh starting point while preserving the LUT", () => { const onCommit = vi.fn(); const base = neutralGrading(); const grading = { ...base, adjust: { ...base.adjust, exposure: 0.4 }, effects: { ...base.effects, blur: 0.6 }, palette: ["#112233", "#ffffff"], lut: { src: "assets/luts/custom.cube", intensity: 0.5 }, }; const { host, root } = renderInto(); const toggle = host.querySelector('[data-flat-effects-add-toggle="true"]'); act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); const retro = host.querySelector( '[data-flat-effect-group="Retro & Glitch"]', ); act(() => retro?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); const vhs = host.querySelector('[data-flat-effect-preset="vhs-playback"]'); act(() => vhs?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); const committed = onCommit.mock.calls[0][0]; expect(committed).toMatchObject({ preset: "vhs-playback", lut: { src: "assets/luts/custom.cube", intensity: 0.5 }, }); expect(committed.effects.tapeDamage).toBeGreaterThan(0); expect(committed.effects.blur).toBe(0); expect(committed.palette).toBeNull(); expect(committed.adjust.exposure).not.toBe(0.4); act(() => root.unmount()); }); it("shows only the selected active effect controls and converts degrees to shader units", () => { const onCommit = vi.fn(); const grading = normalizeHfColorGrading({ effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.chromaticAberration, }); if (!grading) throw new Error("expected chromatic grading"); const { host, root } = renderInto(); expect(host.textContent).toContain("Angle"); const effect = host.querySelector('[data-flat-effect-editor="chromaticAberration"]'); if (!effect) throw new Error("expected chromatic effect"); const rows = effect.querySelectorAll('[data-flat-slider-track="true"]'); const angleTrack = rows[1] as HTMLElement | undefined; if (!angleTrack) throw new Error("expected angle slider"); Object.defineProperty(angleTrack, "getBoundingClientRect", { value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), }); act(() => { angleTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); angleTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); }); expect(onCommit.mock.calls.at(-1)?.[0].effects.chromaticAngle).toBe(0.5); act(() => root.unmount()); }); it("offers native ASCII styles, binary controls, and a bounded custom palette", () => { const onCommit = vi.fn(); const grading = normalizeHfColorGrading({ effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.ascii, }); if (!grading) throw new Error("expected ASCII grading"); const { host, root } = renderInto(); expect( host.querySelector('select[aria-label="Style"]')?.options, ).toHaveLength(8); expect(host.querySelectorAll('[data-flat-toggle="true"]')).toHaveLength(2); expect(host.querySelector('[data-flat-effect-editor="ascii"]')?.textContent).not.toContain( "Mix", ); expect(host.querySelectorAll("[data-flat-effects-palette-preset]")).toHaveLength( HF_COLOR_GRADING_PALETTES.length, ); const synthwave = host.querySelector( '[data-flat-effects-palette-preset="synthwave"]', ); act(() => synthwave?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls.at(-1)?.[0].palette).toEqual( HF_COLOR_GRADING_PALETTES.find(({ id }) => id === "synthwave")?.colors, ); const addPalette = host.querySelector( '[data-flat-effects-add-palette="true"]', ); act(() => addPalette?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls.at(-1)?.[0].palette).toEqual(["#000000", "#ffffff"]); act(() => root.unmount()); }); it("resets the selected effect to its authored defaults", () => { const onCommit = vi.fn(); const grading = normalizeHfColorGrading({ effects: { ...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.digitalGlitch, digitalGlitch: 0.9 }, }); if (!grading) throw new Error("expected digital glitch grading"); const { host, root } = renderInto(); expect( host.querySelector('[data-flat-effect-editor="digitalGlitch"]')?.textContent, ).not.toContain("Mix"); const reset = host.querySelector('[title="Reset Digital Glitch"]'); act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls.at(-1)?.[0].effects).toMatchObject( HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.digitalGlitch, ); act(() => root.unmount()); }); it("removes only the selected effect and preserves another active effect", () => { const onCommit = vi.fn(); const grading = normalizeHfColorGrading({ effects: { ...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.blur, ...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate, }, }); if (!grading) throw new Error("expected multi-effect grading"); const { host, root } = renderInto(); const remove = host.querySelector('[title="Remove Blur"]'); act(() => remove?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls.at(-1)?.[0].effects.blur).toBe(0); expect(onCommit.mock.calls.at(-1)?.[0].effects.pixelate).toBe( HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.pixelate.pixelate, ); act(() => root.unmount()); }); it("closes the catalog when an already active effect is selected", () => { const grading = normalizeHfColorGrading({ effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS.blur, }); if (!grading) throw new Error("expected blur grading"); const props = sectionProps(grading); const { host, root } = renderInto(); expect(props.onRequestEffectPreviews).not.toHaveBeenCalled(); const toggle = host.querySelector('[data-flat-effects-add-toggle="true"]'); act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(toggle?.getAttribute("aria-expanded")).toBe("true"); expect(props.onRequestEffectPreviews).toHaveBeenCalledWith(["blur", "pixelate", "bloom"]); const blur = host.querySelector('[data-flat-effect-option="blur"]'); act(() => blur?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(toggle?.getAttribute("aria-expanded")).toBe("false"); act(() => root.unmount()); }); it("requests only the visible family when the catalog tab changes", () => { const props = sectionProps(neutralGrading()); const { host, root } = renderInto(); const art = host.querySelector('[data-flat-effect-group="Art"]'); act(() => art?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(props.onRequestEffectPreviews).toHaveBeenLastCalledWith([ "ascii", "engraving", "crosshatch", "kuwahara", ]); act(() => root.unmount()); }); }); describe("FlatEffectsAccessory", () => { it("counts active masters and resets only effects and palette", () => { const base = neutralGrading(); const grading = { ...base, adjust: { ...base.adjust, contrast: 0.2 }, effects: { ...base.effects, blur: 0.4, ascii: 1 }, palette: ["#112233", "#ffffff"], }; expect(activeColorGradingEffectCount(grading)).toBe(2); const onCommit = vi.fn(); const { host, root } = renderInto( , ); const reset = host.querySelector('[data-flat-effects-reset="true"]'); act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onCommit.mock.calls[0][0].adjust.contrast).toBe(0.2); expect(onCommit.mock.calls[0][0].effects.blur).toBe(0); expect(onCommit.mock.calls[0][0].effects.ascii).toBe(0); expect(onCommit.mock.calls[0][0].palette).toBeNull(); act(() => root.unmount()); }); });