3ea6c992bc
- tests/previewWorkspaceZoom.test.tsx 新增「树层按 root.offset.min 定位,原图与子节点随之一起移动」:断言树层 style.left/top 等于 root.offset.min 且根与子节点都挂在该层 - 变异验证:层偏移兜底改成 [0,0] 后只有该用例变红(1 failed / 9 passed) - 里程碑第 2 条勾选,6 条验收全部有变异验证过的自动化证据(当前四套 77 passed);实施计划同步更新,剩余只有真机目视验收
319 lines
9.9 KiB
TypeScript
319 lines
9.9 KiB
TypeScript
// @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> = {},
|
|
): 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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
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(
|
|
<PreviewWorkspace
|
|
canvas={createCanvas({
|
|
activeImage: null,
|
|
activeImageId: null,
|
|
tree: null,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
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(<PreviewWorkspace canvas={createCanvas()} />);
|
|
const singleScale = logicalViewportScale(single.container);
|
|
single.unmount();
|
|
|
|
const joint = render(
|
|
<PreviewWorkspace canvas={createCanvas(twoTreeCanvas)} />,
|
|
);
|
|
const jointScale = logicalViewportScale(joint.container);
|
|
// 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。
|
|
expect(jointScale).toBeLessThan(singleScale);
|
|
|
|
// 选中另一棵树的节点只改选中态:不允许顺带重新适配(那会把用户当前视口顶掉)。
|
|
joint.rerender(
|
|
<PreviewWorkspace
|
|
canvas={createCanvas({ ...twoTreeCanvas, selectedNodeId: 'root-2' })}
|
|
/>,
|
|
);
|
|
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(
|
|
<PreviewWorkspace
|
|
canvas={createCanvas({
|
|
uiTrees: [{ src_ui_design: 'page-1', root: movedRoot }],
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
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();
|
|
});
|
|
});
|