import { describe, expect, it } from "vitest"; import { act, defineToolcraft, fireEvent, renderControlsPanelWithSchema, screen, vi, } from "../testing/controls-panel-test-utils"; function createOverflowSchema() { return defineToolcraft({ canvas: { enabled: false }, panels: { controls: { sections: [ { controls: { density: { defaultValue: 50, label: "Density", max: 100, min: 0, target: "field.density", type: "slider", }, }, title: "Field", }, { controls: { enabled: { defaultValue: true, label: "Enabled", target: "wind.enabled", type: "switch", }, }, title: "Wind", }, { actionGroup: "secondary", controls: { export: { actions: [ { command: "controls.apply", label: "Export", value: "export", variant: "default", }, ], target: "panel.actions", type: "panelActions", }, }, title: "Export", }, ], title: "Grass Studio", }, }, }); } function createRect({ height, left = 0, top, width = 300, }: { height: number; left?: number; top: number; width?: number; }): DOMRect { return { bottom: top + height, height, left, right: left + width, toJSON: () => ({}), top, width, x: left, y: top, }; } function prepareNavigationPanel(container: HTMLElement) { const root = container.querySelector( "[data-toolcraft-controls-panel-shell]", ); const viewport = container.querySelector( '[data-slot="toolcraft-panel-content"]', ); const panel = container.querySelector( '[data-panel-id="properties"]', ); const sectionAnchors = Array.from( container.querySelectorAll( "[data-toolcraft-controls-section-anchor]", ), ); expect(root).toBeTruthy(); expect(panel).toBeTruthy(); expect(viewport).toBeTruthy(); expect(sectionAnchors).toHaveLength(4); vi.spyOn(root!, "getBoundingClientRect").mockReturnValue( createRect({ height: 360, left: 400, top: 10 }), ); vi.spyOn(panel!, "getBoundingClientRect").mockReturnValue( createRect({ height: 360, left: 400, top: 10 }), ); vi.spyOn(viewport!, "getBoundingClientRect").mockReturnValue( createRect({ height: 300, left: 400, top: 60 }), ); sectionAnchors.forEach((section, index) => { vi.spyOn(section, "getBoundingClientRect").mockReturnValue( createRect({ height: 180, left: 400, top: 60 + index * 180 }), ); }); return { panel: panel!, viewport: viewport! }; } function setViewportSize( viewport: HTMLElement, { clientHeight, scrollHeight, }: { clientHeight: number; scrollHeight: number }, ): void { Object.defineProperties(viewport, { clientHeight: { configurable: true, value: clientHeight }, scrollHeight: { configurable: true, value: scrollHeight }, scrollTop: { configurable: true, value: 0, writable: true }, }); } function refreshNavigationLayout(): void { act(() => { window.dispatchEvent(new Event("resize")); vi.advanceTimersByTime(20); }); } describe("ControlsPanel section navigation overflow eligibility", () => { it("never mounts navigation when every section fits the content viewport", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema(createOverflowSchema()); const { panel, viewport } = prepareNavigationPanel(container); setViewportSize(viewport, { clientHeight: 500, scrollHeight: 500 }); refreshNavigationLayout(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(600)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); } finally { vi.useRealTimers(); } }); it("requires a fresh dwell after overflow disappears during reveal intent", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema(createOverflowSchema()); const { panel, viewport } = prepareNavigationPanel(container); setViewportSize(viewport, { clientHeight: 300, scrollHeight: 900 }); refreshNavigationLayout(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(150)); setViewportSize(viewport, { clientHeight: 900, scrollHeight: 900 }); refreshNavigationLayout(); setViewportSize(viewport, { clientHeight: 300, scrollHeight: 900 }); refreshNavigationLayout(); act(() => vi.advanceTimersByTime(500)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerMove(panel, { clientX: 413, clientY: 120 }); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(299)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); act(() => vi.advanceTimersByTime(1)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); } finally { vi.useRealTimers(); } }); it("unmounts an open popup when overflow disappears", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema(createOverflowSchema()); const { panel, viewport } = prepareNavigationPanel(container); setViewportSize(viewport, { clientHeight: 300, scrollHeight: 900 }); refreshNavigationLayout(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); setViewportSize(viewport, { clientHeight: 900, scrollHeight: 900 }); refreshNavigationLayout(); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); setViewportSize(viewport, { clientHeight: 300, scrollHeight: 900 }); refreshNavigationLayout(); act(() => vi.advanceTimersByTime(500)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerMove(panel, { clientX: 413, clientY: 120 }); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); } finally { vi.useRealTimers(); } }); });