修复画布旧测试的逻辑缩放断言
Project CI / Repository checks (pull_request) Successful in 3m56s
Project CI / Frontend tests (pull_request) Successful in 4m31s
Project CI / Native shell tests (pull_request) Successful in 21m16s
Project CI / Backend tests (pull_request) Successful in 49m46s

抽取并导出逻辑 viewport 到渲染 transform 的共享转换函数

更新网站与 Tauri 画布测试,通过逻辑坐标和缩放值验证实际样式

同步画布公开契约和测试边界文档
This commit is contained in:
2026-09-05 14:52:24 +08:00
parent f24949f732
commit be0ae6d716
9 changed files with 68 additions and 23 deletions
@@ -11,6 +11,7 @@ import type {
ImageCanvasHostScope,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -1125,7 +1126,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
document.querySelector<HTMLElement>('.genarrative-image-canvas__world')
?.style.transform,
).toBe('translate(-2570px, -2495px) scale(1)');
).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
});
@@ -1170,7 +1171,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
document.querySelector<HTMLElement>('.genarrative-image-canvas__world')
?.style.transform,
).toBe('translate(-2570px, -2495px) scale(1)');
).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
fireEvent.click(layerButton);
@@ -2062,7 +2063,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 60,
});
await waitFor(() =>
expect(world.style.transform).toBe('translate(30px, 30px) scale(0.5)'),
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 30, y: 30, scale: 0.5 }),
),
);
fireEvent.pointerMove(window, {
pointerId: 16,
@@ -2070,7 +2073,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 30,
});
await waitFor(() =>
expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'),
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
),
);
fireEvent.pointerUp(window, {
pointerId: 16,
@@ -2315,7 +2320,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)');
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
);
fireEvent.wheel(viewport, {
deltaX: 4,
@@ -2324,7 +2331,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 80,
});
await waitFor(() =>
expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'),
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -4, y: -10, scale: 0.5 }),
),
);
fireEvent.wheel(viewport, {
deltaX: 0,
@@ -2334,7 +2343,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
shiftKey: true,
});
await waitFor(() =>
expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'),
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -14, y: -10, scale: 0.5 }),
),
);
fireEvent.wheel(viewport, {
deltaX: 0,
@@ -2344,7 +2355,13 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
ctrlKey: true,
});
await waitFor(() =>
expect(world.style.transform).not.toContain('scale(0.5)'),
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({
x: 100 - 228 * 0.55,
y: 80 - 180 * 0.55,
scale: 0.55,
}),
),
);
});
@@ -7951,10 +7951,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移
- 背景:资源总览页曾在业务层重复实现 world 的 transform、动态矩形尺寸和 2× `zoom`与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 形成第二套缩放逻辑;超采样还会让 SVG 卡片图标的线条产生视觉加粗
- 决策:`CanvasWorld` 默认统一采用 2× 超采样,内容按 `zoom: 2` 排版,外层使用 `viewport.scale / 2`,最终视觉比例`viewport.scale` 定义。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`
- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一
- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`
- 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。
- 验证方式:共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding``git diff --check`;重点检查 `scale=1/2` 的最终 transform、SVG 描边、拖拽坐标、关系线端点和默认 `12000 × 12000` world。
- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding``git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸
## 2026-08-31 AGC 错误报告与诊断上传
@@ -549,7 +549,7 @@ game-project/
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
- 2026-09-05 起所有 `@genarrative/image-canvas-react``CanvasWorld` 默认使用统一 2× 超采样:内容层按 `zoom: 2` 排版,外层 transform 使用 `viewport.scale / 2`,因此 `viewport.scale` 表示最终视觉比例`1` 为原始大小,`2` 为放大两倍)。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
- `@genarrative/image-canvas-react``CanvasWorld` 接收逻辑 `viewport``viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类
@@ -4,7 +4,10 @@ import {
} from '@genarrative/image-canvas-core';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
const CANVAS_RENDER_SUPERSAMPLE = 2;
import {
CANVAS_RENDER_SUPERSAMPLE,
canvasViewportToWorldTransform,
} from './canvasWorldRendering';
export function CanvasWorld({
children,
@@ -30,7 +33,7 @@ export function CanvasWorld({
...style,
width: worldSize,
height: worldSize,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewportScale / CANVAS_RENDER_SUPERSAMPLE})`,
transform: canvasViewportToWorldTransform(viewport),
'--genarrative-image-canvas-inverse-scale': String(1 / viewportScale),
} as CSSProperties
}
@@ -0,0 +1,10 @@
import type { CanvasViewport } from '@genarrative/image-canvas-core';
export const CANVAS_RENDER_SUPERSAMPLE = 2;
/** 将逻辑 viewport 转换为 world 的渲染 transform。 */
export function canvasViewportToWorldTransform(viewport: CanvasViewport) {
const scale =
Number.isFinite(viewport.scale) && viewport.scale > 0 ? viewport.scale : 1;
return `translate(${viewport.x}px, ${viewport.y}px) scale(${scale / CANVAS_RENDER_SUPERSAMPLE})`;
}
+1
View File
@@ -2,6 +2,7 @@ export * from './CanvasChrome';
export * from './CanvasPortal';
export * from './CanvasViewport';
export * from './CanvasWorld';
export { canvasViewportToWorldTransform } from './canvasWorldRendering';
export * from './LayerRenderer';
export * from './Minimap';
export * from './SelectionOverlay';
@@ -6,6 +6,10 @@ import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CanvasWorld } from './CanvasWorld';
import {
CANVAS_RENDER_SUPERSAMPLE,
canvasViewportToWorldTransform,
} from './canvasWorldRendering';
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
function layer(id: string, x: number): CanvasLayer {
@@ -64,11 +68,13 @@ describe('shared canvas React lifecycle', () => {
const content = screen.getByText('content').parentElement;
expect(world?.style.width).toBe('12000px');
expect(world?.style.height).toBe('12000px');
expect(world?.style.transform).toBe('translate(12px, 24px) scale(1)');
expect(world?.style.transform).toBe(
canvasViewportToWorldTransform({ x: 12, y: 24, scale: 2 }),
);
expect(
world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'),
).toBe('0.5');
expect(content?.style.zoom).toBe('2');
expect(content?.style.zoom).toBe(String(CANVAS_RENDER_SUPERSAMPLE));
});
it('removes every native listener across repeated mounts', () => {
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -2412,11 +2413,13 @@ describe('ImageCanvasEditorView', () => {
act(() => rafCallbacks.shift()?.(210));
expect(world.style.transform).not.toBe(initialTransform);
expect(world.style.transform).not.toBe(
'translate(-840px, -246.5px) scale(1)',
canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }),
);
act(() => rafCallbacks.shift()?.(420));
expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)');
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }),
);
expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy();
expect(
puzzleLayer.classList.contains('image-canvas-editor__layer--selected'),
@@ -2532,7 +2535,9 @@ describe('ImageCanvasEditorView', () => {
clientX: 400,
clientY: 280,
});
expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)');
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -80, y: -120, scale: 0.5 }),
);
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
@@ -2567,7 +2572,7 @@ describe('ImageCanvasEditorView', () => {
).toBeTruthy();
expect(
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
).toBe('translate(0px, 0px) scale(0.5)');
).toBe(canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }));
});
it('selects multiple canvas layers with shift click', async () => {
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -416,13 +417,15 @@ describe('ImageCanvasWorldView', () => {
).toBe('344px');
expect(screen.getByText('生成中')).toBeTruthy();
const world = document.querySelector('.image-canvas-editor__world');
const world = document.querySelector<HTMLElement>(
'.image-canvas-editor__world',
);
const marquee = world?.querySelector(
'.image-canvas-editor__canvas-marquee',
);
expect(world?.getAttribute('style')).toContain(
'transform: translate(10px, 20px) scale(1.5)',
expect(world?.style.transform).toBe(
canvasViewportToWorldTransform({ x: 10, y: 20, scale: 1.5 }),
);
expect((marquee as HTMLElement | null)?.style.width).toBe('100px');
expect((marquee as HTMLElement | null)?.style.height).toBe('120px');