// @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; import { PREVIEW_GRID_BASE_STEP, resolvePreviewGridStep, } from '../src/view/ui-editor/components/preview/previewGrid'; import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; class TestResizeObserver { observe() {} disconnect() {} unobserve() {} } const root: UiNode = { id: 'root', layout: { transform: { anchor_min: [0, 0], anchor_max: [1, 1], offset_min: [0, 0], offset_max: [0, 0], }, custom_minimum_size: [0, 0], size_flags_horizontal: 1, size_flags_vertical: 1, size_flags_stretch_ratio: 1, container: 'None', }, metadata: { name: '根节点', description: '', layout_status: 'NoProblem', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, component: null, children_display_mode: 'Stack', children: [], offset: { min: [0, 0], max: [1200, 800], }, }; const activeImage: UIDesignImage = { path: 'assets/page.png', pixel_size: [1200, 800], pixels_per_unit: 1, }; function createCanvas( overrides: Partial = {}, ): UiEditorCanvasProjection { return { isLocked: false, activeImage, activeImageId: 'page-1', uiTrees: [{ src_ui_design: 'page-1', root }], previewUrls: { 'page-1': 'data:image/png;base64,' }, images: { 'page-1': activeImage }, sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, hiddenNodeIds: new Set(), focusRequest: null, status: null, isNodePreviewVisible: vi.fn(() => true), toggleNodePreviewVisibility: vi.fn(), selectExclusiveChild: vi.fn(), selectNode: vi.fn(), clearNodeSelection: vi.fn(), updateNodeTransform: vi.fn(), insertNode: vi.fn(), insertNodeAfter: vi.fn(), deleteNode: vi.fn(), openClearDialog: vi.fn(), ...overrides, }; } function logicalViewportScale(container: HTMLElement) { const world = container.querySelector( '.genarrative-image-canvas__world', ) as HTMLElement; const inverseScale = Number( world.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'), ); if (!Number.isFinite(inverseScale) || inverseScale <= 0) { throw new Error(`无法从 world 读取逻辑缩放值`); } return 1 / inverseScale; } beforeEach(() => { vi.stubGlobal('ResizeObserver', TestResizeObserver); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('PreviewWorkspace quick zoom', () => { it('chooses doubled or halved world steps to keep the grid readable', () => { expect(resolvePreviewGridStep(1)).toBe(PREVIEW_GRID_BASE_STEP); expect(resolvePreviewGridStep(0.5)).toBe(56); expect(resolvePreviewGridStep(0.125)).toBe(224); expect(resolvePreviewGridStep(2)).toBe(14); expect(resolvePreviewGridStep(4)).toBe(7); for (const scale of [0.125, 0.25, 0.5, 1, 2, 4, 8]) { const spacing = resolvePreviewGridStep(scale) * scale; expect(spacing).toBeGreaterThanOrEqual(20); expect(spacing).toBeLessThan(40); } }); it('falls back to the base step when the screen spacing overflows', () => { // 溢出成 Infinity 时步长无法收敛:必须直接回基础步长,不能死循环。 expect(resolvePreviewGridStep(Number.MAX_VALUE)).toBe( PREVIEW_GRID_BASE_STEP, ); }); it('applies the adaptive screen-space spacing to the preview background', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const scale = logicalViewportScale(rendered.container); const [backgroundWidth] = preview.style.backgroundSize.split('px'); expect(Number(backgroundWidth)).toBeCloseTo( resolvePreviewGridStep(scale) * scale, ); }); it('uses the toolbar zoom step while the preview is hovered', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const before = logicalViewportScale(rendered.container); fireEvent.pointerEnter(preview); const wasNotCancelled = fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true, cancelable: true, }); expect(wasNotCancelled).toBe(false); expect(logicalViewportScale(rendered.container)).toBeCloseTo(before * 1.16); }); it('requires hover or focus and ignores interactive targets', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const zoomButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); expect( fireEvent.keyDown(window, { key: '=', ctrlKey: true, cancelable: true, }), ).toBe(true); expect(logicalViewportScale(rendered.container)).toBe(initial); fireEvent.pointerEnter(preview); expect( fireEvent.keyDown(zoomButton, { key: '=', ctrlKey: true, cancelable: true, }), ).toBe(true); expect(logicalViewportScale(rendered.container)).toBe(initial); fireEvent.pointerLeave(preview); fireEvent.focus(preview); fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true, cancelable: true, }); expect(logicalViewportScale(rendered.container)).toBeCloseTo( initial * 0.86, ); }); it('keeps the actual-size shortcut within the preview scope', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); fireEvent.focus(preview); fireEvent.keyDown(window, { key: '1', ctrlKey: true, cancelable: true, }); expect(logicalViewportScale(rendered.container)).toBe(1); }); it('refits the preview when the zoom percentage button is clicked', () => { const rendered = render(); const fitButton = screen.getByRole('button', { name: '适配画布' }); const zoomInButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); fireEvent.click(zoomInButton); expect(logicalViewportScale(rendered.container)).toBeCloseTo( initial * 1.16, ); fireEvent.click(fitButton); expect(logicalViewportScale(rendered.container)).toBeCloseTo(initial); }); it('leaves browser zoom untouched when the preview has no content', () => { render( , ); expect( fireEvent.keyDown(window, { key: '=', ctrlKey: true, cancelable: true, }), ).toBe(true); }); it('进入时按全部树的联合边界适配,选中节点不重新适配', () => { const secondRoot: UiNode = { ...structuredClone(root), id: 'root-2', offset: { min: [1600, 0], max: [2800, 800] }, }; const twoTrees = [ { src_ui_design: 'page-1', root }, { src_ui_design: 'page-2', root: secondRoot }, ]; // 第二棵树的界面图必须也在投影里,否则它不会进入联合边界(`allBounds` 只收集尺寸有效的树)。 const twoTreeCanvas = { uiTrees: twoTrees, images: { 'page-1': activeImage, 'page-2': activeImage }, previewUrls: { 'page-1': 'data:image/png;base64,', 'page-2': 'data:image/png;base64,', }, }; const single = render(); const singleScale = logicalViewportScale(single.container); single.unmount(); const joint = render( , ); const jointScale = logicalViewportScale(joint.container); // 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。 expect(jointScale).toBeLessThan(singleScale); // 选中另一棵树的节点只改选中态:不允许顺带重新适配(那会把用户当前视口顶掉)。 joint.rerender( , ); expect(logicalViewportScale(joint.container)).toBe(jointScale); }); it('树层按 root.offset.min 定位,原图与子节点随之一起移动', () => { const child: UiNode = { ...structuredClone(root), id: 'child', offset: { min: [10, 20], max: [210, 220] }, }; const movedRoot: UiNode = { ...structuredClone(root), offset: { min: [40, 60], max: [1240, 860] }, children: [child], }; const view = render( , ); const layer = view.container.querySelector( '[data-tree-id="page-1"]', ) as HTMLElement; expect(layer.style.left).toBe('40px'); expect(layer.style.top).toBe('60px'); // 原图与全部子节点都挂在这一层里:整树平移由这一层统一承担,子节点不必各自改坐标。 expect(layer.querySelector('[data-node-id="root"]')).toBeTruthy(); expect(layer.querySelector('[data-node-id="child"]')).toBeTruthy(); }); });