From 3bf499ddfd3584a1ac9be790490d5b003deec3eb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 13 Sep 2026 17:06:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=8C=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E3=80=8D=E5=B1=95=E5=BC=80=E6=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8D=A1=E4=B8=8E=E5=9B=BE=E7=89=87=E5=8D=A1=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E8=87=AA=E5=8A=A8=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resourceBookLayout:新增分带几何缓存键 resourceBookAllLayoutKey 与取几何的 resolveResourceBookAllLayout,键纳入布局是否就绪 / 卡片真实尺寸 / 排序模式,拖动只改坐标时键保持不变 - resourceBookLayout:抽出 ResourceBookAllLayoutInput,生产组件与回归用例共用同一份「键 + 缓存」口径 - index.tsx:展开态分带几何改走 resolveResourceBookAllLayout,并传入当前布局 hook 的 ready;此前只按项目 / 排序 / 可见资源 id 冻结,依赖模式关系图就绪前算出的单行带(144 高)被永久固化,图片带仍挤在 144 + 48 处,整排压住文档卡 - tests/resourceBookLayout.test.ts:新增混合类型(12 文档 + 12 图片)第一版坐标到位后分带重算、逐对矩形不相交的主用例 - tests/resourceBookLayout.test.ts:新增同一栏混排不同尺寸卡(类型 / 依赖两种模式)不相交、sidecar 手放坐标保留并让开下一带的对照用例 - tests/resourceBookLayout.test.ts:新增缓存键的拖动稳定性、就绪翻转、卡片尺寸与排序模式变化用例 - tests/projectResourceLiveIntegration.test.tsx:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查 --- .../src/view/project-development/index.tsx | 64 +-- .../project-development/resourceBookLayout.ts | 117 ++++- .../projectResourceLiveIntegration.test.tsx | 171 +++++++ .../tests/resourceBookLayout.test.ts | 455 +++++++++++++++++- 4 files changed, 767 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a05986ba5..60842a4f6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -211,10 +211,10 @@ import { type ResourceBookTransitionController, } from './resourceBookController'; import { - buildResourceBookAllLayout, buildResourceBookScenePlan, + resolveResourceBookAllLayout, resourceBookAllBandLocalPoint, - type ResourceBookAllLayout, + type ResourceBookAllLayoutCache, type ResourceBookOverviewRect, resourceBookSceneCardKey, resourceBookSceneCardLayoutSignature, @@ -2382,6 +2382,7 @@ export default function ProjectDevelopmentView({ const activeResourceLayout = sortMode === 'dependency' ? dependencyLayout : typeLayout; const resourceLayout = activeResourceLayout.layout; + const resourceLayoutReady = activeResourceLayout.ready; const resourceLayoutNotice = activeResourceLayout.notice; const resourceLayoutSaving = activeResourceLayout.saving; /** @@ -2518,39 +2519,42 @@ export default function ProjectDevelopmentView({ * 展开态的分带几何,以及它的**冻结**口径。 * * 卡片坐标是栏目内局部坐标(见 `resourceCanvasSectionExtent`),铺进同一张画布前必须各给 - * 一个带原点,否则各栏目会全叠在原点。带几何只在 `项目 / 排序 / 可见资源集` 变化时重算, - * **拖动改坐标不重算**:否则拖动会让带高跟着 extent 变,一整列后面的带会跟着整体位移。 - * 实现是"签名 + ref 短路",签名里**不含 `resourceBookState.token`** —— 返回总览时 token - * 也会变,若含它就会在 `returning-main` 的淡出期间重算一遍,正在淡出的卡会贴到新带上。 + * 一个带原点,否则各栏目会全叠在原点。带几何只在 `项目 / 排序 / 布局就绪 / 卡片尺寸 / + * 可见资源集` 变化时重算,**拖动改坐标不重算**:否则拖动会让带高跟着 extent 变, + * 一整列后面的带会跟着整体位移。 + * + * 键与缓存的判定收在 `resolveResourceBookAllLayout` 一处(生产组件与回归用例共用同一口径): + * 曾经这里只按 `项目 / 排序 / 可见资源 id` 冻结,于是依赖模式"关系图就绪前没有坐标"时 + * 算出的单行带(144 高)被永久固化,坐标到位后文档卡铺到第 3 行、图片那一带仍挤在 + * `144 + 48` 处,整排图片压在文档卡上(「一开始自动重叠」)。 + * + * 键里**不含 `resourceBookState.token`**:返回总览时 token 也会变,若含它就会在 + * `returning-main` 的淡出期间重算一遍,正在淡出的卡会贴到新带上。 */ - const resourceBookAllLayoutKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${visibleResources - .map((resource) => resource.id) - .sort() - .join(',')}`; - const resourceBookAllLayoutRef = useRef<{ - key: string; - layout: ResourceBookAllLayout; - } | null>(null); + const resourceBookAllLayoutRef = useRef( + null, + ); const resourceBookAllLayout = useMemo(() => { - const frozen = resourceBookAllLayoutRef.current; - if (frozen && frozen.key === resourceBookAllLayoutKey) { - return frozen.layout; - } - const layout = buildResourceBookAllLayout({ - categoryOrder, - resourcesByCategory: visibleResourcesByCategory, - positions: resourcePositionById, - cardSizes: resourceCardSizeByResourceId, - }); - resourceBookAllLayoutRef.current = { - key: resourceBookAllLayoutKey, - layout, - }; - return layout; + const resolved = resolveResourceBookAllLayout( + resourceBookAllLayoutRef.current, + { + projectId: manifest.projectId, + mode: sortMode, + layoutReady: resourceLayoutReady, + categoryOrder, + resourcesByCategory: visibleResourcesByCategory, + positions: resourcePositionById, + cardSizes: resourceCardSizeByResourceId, + }, + ); + resourceBookAllLayoutRef.current = resolved; + return resolved.layout; }, [ - resourceBookAllLayoutKey, + manifest.projectId, resourceCardSizeByResourceId, + resourceLayoutReady, resourcePositionById, + sortMode, visibleResourcesByCategory, ]); /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index 71ee3ccdb..454c05448 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -1,3 +1,4 @@ +import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { ResourceBookCardPresentation, ResourceBookCategory, @@ -106,17 +107,19 @@ export const RESOURCE_BOOK_ALL_BAND_GAP = 48; * * 这里的产物只描述几何,不参与任何持久化:落盘仍只有栏目内局部坐标。 */ +export type ResourceBookAllLayoutInput = { + categoryOrder: readonly ResourceBookCategory[]; + resourcesByCategory: ReadonlyMap; + positions: ReadonlyMap; + cardSizes: ReadonlyMap; +}; + export function buildResourceBookAllLayout({ categoryOrder, resourcesByCategory, positions, cardSizes, -}: { - categoryOrder: readonly ResourceBookCategory[]; - resourcesByCategory: ReadonlyMap; - positions: ReadonlyMap; - cardSizes: ReadonlyMap; -}): ResourceBookAllLayout { +}: ResourceBookAllLayoutInput): ResourceBookAllLayout { const bands: ResourceBookAllBand[] = []; const bandByCategory = new Map(); let cursorTop = 0; @@ -170,6 +173,98 @@ export function buildResourceBookAllLayout({ }; } +/** 分带几何的作用域:项目 / 排序模式 / 当前布局是否已经就绪。 */ +export type ResourceBookAllLayoutScope = { + projectId: string; + mode: ProjectResourceCanvasLayoutMode; + /** + * 当前排序模式的布局 hook 是否已经就绪(sidecar 已读回、或确认不需要读)。 + * + * 就绪前 hook 会回退到"没有坐标"的布局,此时算出来的带几何是"所有卡都堆在原点"的 + * 那份;若不把它当键的一部分,键在坐标到位时不变,缓存就会把那份错误的带几何一直用到 + * 会话结束。 + */ + layoutReady: boolean; +}; + +export type ResourceBookAllLayoutCache = { + key: string; + layout: ResourceBookAllLayout; +}; + +/** + * 分带几何的缓存键。 + * + * **带几何必须跟卡片几何同源**:带高取该栏目可见资源的 extent,带原点由前一条带的带高 + * 累加;卡片画的是"栏目内局部坐标 + 带原点"。所以键里少一个会变的输入,就会留下一条 + * 用旧几何画的带: + * + * - **坐标是否到位**(`layoutReady` + 每张可见资源都能查到坐标):依赖模式下关系图就绪前 + * 布局是空的,此时算出的带高只有"单行"(`128 + 16 = 144`);坐标到位后键若不变, + * 文档那一带仍是 144 高,而卡片已经铺到第 2、3 行(`336 + 128 = 464`),下一条带 + * (图片)被摆在 `144 + 48` 处,直接压在文档卡上 —— 这正是「一开始自动重叠」。 + * - **卡片尺寸**:图片卡的实际尺寸由预览探测出来的像素尺寸决定(最宽 220、最高 180), + * 探测前一律按 180×128 算。尺寸变而键不变,卡片就会长出带框、压到下一条带上。 + * - **排序模式**:两个模式各有一份坐标,模式变了坐标就变,键必须跟着变。 + * + * 键里**故意不含坐标的具体取值**:拖动只改坐标、键不变,带几何因此不会跟着抖 + * (见 `resolveResourceBookAllLayout` 的说明)。 + */ +export function resourceBookAllLayoutKey({ + projectId, + mode, + layoutReady, + categoryOrder, + resourcesByCategory, + positions, + cardSizes, +}: ResourceBookAllLayoutScope & ResourceBookAllLayoutInput): string { + const resourceIdentities = categoryOrder + .flatMap((category) => resourcesByCategory.get(category) ?? []) + .map((resource) => { + const size = + cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE; + return `${resource.id}:${size.width}x${size.height}`; + }) + .sort(); + const settled = + layoutReady && + categoryOrder.every((category) => + (resourcesByCategory.get(category) ?? []).every((resource) => + positions.has(resource.id), + ), + ); + return JSON.stringify([ + projectId, + mode, + settled ? 'settled' : 'pending', + resourceIdentities, + ]); +} + +/** + * 取分带几何:键变了才重算,键没变就复用缓存里的那份。 + * + * 这条"键 + 缓存"是展开态唯一的几何口径(生产组件与回归用例共用它),它同时钉住两件 + * 互相拉扯的事: + * + * 1. **必须重算**:坐标 / 卡片尺寸 / 排序模式 / 项目 / 可见资源集、以及"坐标还没到位"这一 + * 状态发生变化时,上一条带的位置已经不再匹配卡片,必须重算,否则卡片会画到带框之外、 + * 压到下一条带上(跨栏目重叠)。 + * 2. **不跟着拖动抖**:拖动只改坐标,键不变。带高若是每次拖动都跟着 extent 变, + * 一整列后面的带会整体位移,用户拖一张卡会把整页顶走。 + */ +export function resolveResourceBookAllLayout( + cache: ResourceBookAllLayoutCache | null, + input: ResourceBookAllLayoutScope & ResourceBookAllLayoutInput, +): ResourceBookAllLayoutCache { + const key = resourceBookAllLayoutKey(input); + if (cache && cache.key === key) { + return cache; + } + return { key, layout: buildResourceBookAllLayout(input) }; +} + /** * 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。 * @@ -460,9 +555,13 @@ export function buildResourceBookScenePlan({ * 只服务 z-index,而展开态不设 z-index(见 `ResourceBookScene`)。 */ const stackColumnByResourceId = new Map(); - groupResourceBookStacks(categoryResources).forEach(([, items], column) => { - items.forEach((item) => stackColumnByResourceId.set(item.id, column)); - }); + groupResourceBookStacks(categoryResources).forEach( + ([, items], column) => { + items.forEach((item) => + stackColumnByResourceId.set(item.id, column), + ); + }, + ); categoryResources.forEach((resource) => { cards.push({ key: resourceBookSceneCardKey(resource.id), diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index ebf07996a..d36de54c6 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -316,6 +316,135 @@ function DerivedWorkbench({ ); } +type ExpandedSceneCard = { + resourceId: string; + categoryBadge: string; + left: number; + top: number; + right: number; + bottom: number; +}; + +/** + * 展开态画本里每张卡的世界矩形。 + * + * `--resource-x/y` 与卡片宽高就是渲染层按画本计划写上的几何(见资源卡的宿主样式), + * 所以这里读到的是"卡片被画在哪儿",而不是测试自己重算的一份平行几何。 + */ +function readExpandedSceneCards(): ExpandedSceneCard[] { + const hosts = Array.from( + document.querySelectorAll( + '[data-resource-book-view="child"] .game-resource-book-scene-world .game-resource-book-scene-card.is-expanded .game-resource-card', + ), + ); + return hosts.flatMap((host) => { + const resourceId = host.getAttribute('data-resource-card-id'); + const style = (host as HTMLElement).style; + const left = Number.parseFloat( + style.getPropertyValue('--resource-x').replace('px', ''), + ); + const top = Number.parseFloat( + style.getPropertyValue('--resource-y').replace('px', ''), + ); + const width = Number.parseFloat( + style.getPropertyValue('--resource-card-width').replace('px', ''), + ); + const height = Number.parseFloat( + style.getPropertyValue('--resource-card-height').replace('px', ''), + ); + if (!resourceId || !Number.isFinite(left + top + width + height)) { + return []; + } + return [ + { + resourceId, + categoryBadge: + host.querySelector('.game-resource-card-type-badge')?.textContent ?? + '', + left, + top, + right: left + width, + bottom: top + height, + }, + ]; + }); +} + +/** 逐对矩形相交检查:红的时候直接列出撞在一起的那两张卡的矩形。 */ +function intersectingSceneCards(cards: readonly ExpandedSceneCard[]): string[] { + const intersections: string[] = []; + for (let left = 0; left < cards.length; left += 1) { + for (let right = left + 1; right < cards.length; right += 1) { + const a = cards[left]!; + const b = cards[right]!; + if ( + a.left < b.right && + b.left < a.right && + a.top < b.bottom && + b.top < a.bottom + ) { + intersections.push( + `${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` + + `${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`, + ); + } + } + } + return intersections; +} + +/** + * 文档 + 图片混排的宿主:6 份文档(`category: 'document'` → 文档栏)与 6 张图片 + * (`kind: 'art-image'` → 待归类栏)。两种卡在依赖模式下都是 180 宽的卡,但落在两条 + * 不同的栏目带上 —— 重叠只可能来自带几何,不可能是同一栏里的排布。 + */ +function MixedCategoryWorkbench() { + const initial = createGameCreationAppManifest( + 'live-canvas-project', + '实时画布项目', + ); + initial.assets = [ + ...Array.from({ length: 6 }, (_, index) => ({ + id: `asset-doc-${index + 1}`, + kind: 'document', + category: 'document' as const, + mediaType: 'text/markdown', + localPath: `docs/note-0${index + 1}.md`, + source: { + kind: 'generated' as const, + resourceId: `doc-resource-${index + 1}`, + }, + })), + ...Array.from({ length: 6 }, (_, index) => ({ + id: `asset-image-${index + 1}`, + kind: 'art-image', + mediaType: 'image/png', + localPath: `assets/img-0${index + 1}.png`, + source: { + kind: 'generated' as const, + resourceId: `image-resource-${index + 1}`, + }, + })), + ]; + const [manifest, setManifest] = useState(initial); + canvasFixture.manifest = manifest; + + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + ); +} + function SwitchableDerivedWorkbench({ onManifestChange, }: { @@ -1885,4 +2014,46 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(queryResourceSelectButton('hero.png')).toBeNull(); }); + + /** + * 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动 + * 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。 + * + * 真宿主链路:依赖排序下,关系图读回之前布局 hook 给的是"没有坐标"的布局;展开态的分带 + * 几何当时按"所有卡都在原点"算出来(文档带只有单行 144 高),坐标到位后若带几何不重算, + * 图片栏就仍被摆在 144 + 48 处,而文档卡已经铺到 y = 168 / 336。 + * + * 判据取渲染出来的卡片世界矩形(`--resource-x/y` 与卡片尺寸就是画本按计划写上的几何), + * 逐对做矩形相交检查:jsdom 没有排版引擎,这是"画出来的位置"在测试环境里能拿到的最强证据。 + */ + it('「所有资源」展开态:文档卡与图片卡初始自动布局逐对不相交', async () => { + installTauri(); + render(); + + // 依赖排序:坐标等关系图读回(这就是用户看到的"一开始")。 + fireEvent.click(await screen.findByRole('button', { name: '按依赖' })); + fireEvent.click( + await screen.findByRole('button', { name: '打开所有资源' }), + ); + + const cards = await waitFor(() => { + const rendered = readExpandedSceneCards(); + expect(rendered.length).toBe(12); + return rendered; + }); + + // 前提自检:两个栏目都真的有卡(不是"只有一种类型的卡"导致用例空转)。 + expect(new Set(cards.map((card) => card.categoryBadge))).toEqual( + new Set(['文档', '待归类']), + ); + const documentTops = new Set( + cards + .filter((card) => card.categoryBadge === '文档') + .map((card) => card.top), + ); + // 文档栏至少两行:单行时"带高压住下一带"的现象根本构造不出来,用例会失去意义。 + expect(documentTops.size).toBeGreaterThan(1); + + expect(intersectingSceneCards(cards)).toEqual([]); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index ec491e7b5..cd3cfe204 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -4,11 +4,13 @@ import { buildResourceBookAllLayout, buildResourceBookScenePlan, groupResourceBookStacks, + resolveResourceBookAllLayout, RESOURCE_BOOK_ALL_BAND_GAP, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, type ResourceBookAllBand, resourceBookAllBandLocalPoint, type ResourceBookAllLayout, + resourceBookAllLayoutKey, resourceBookOverviewCardLayout, type ResourceBookOverviewRect, resourceBookSceneCardKey, @@ -19,6 +21,14 @@ import { type ResourceBookCategory, type ResourceBookTarget, } from '../src/view/project-development/resourceBookModel'; +import { + createEmptyResourceCanvasLayout, + reconcileResourceCanvasLayout, + RESOURCE_CANVAS_SECTION_ORDER, + type ResourceCanvasCardSize, + resourceCanvasImageCardSize, + type ResourceCanvasItem, +} from '../src/view/project-development/resourceCanvasLayoutModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; function resource( @@ -528,7 +538,9 @@ describe('buildResourceBookScenePlan', () => { allLayout, }); - const cards = plan.find((group) => group.category === 'unclassified')!.cards; + const cards = plan.find( + (group) => group.category === 'unclassified', + )!.cards; expect(cards.map((card) => card.resource.id)).toEqual( mixed.map((item) => item.id), ); @@ -680,3 +692,444 @@ describe('resourceBookAllBandLocalPoint', () => { ).toEqual({ x: 7, y: 9 }); }); }); + +/** + * 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。 + * + * 场景与反馈里的项目同形:12 份 Agent 文本回执落在「文档」栏(180×128 的宽卡), + * 12 张 1:1 小图落在「待归类」栏(探测出像素尺寸后是 180×180 的高卡)。两者尺寸不同, + * 分带几何必须按各自真实尺寸算,且必须在坐标到位后重算 —— 这两件事分别由本组用例钉住。 + */ +function documentResource(index: number): ProjectResource { + return { + id: `agent-result:design:run-${index}`, + category: 'document', + subtype: 'agent-result', + label: `测试文档 0${index}`, + path: `专业 Agent 文本回执 · ${index}`, + mediaType: 'Agent 历史文本回执', + sourceLabel: '历史成果', + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + content: `测试文档 ${index} 内容`, + }; +} + +function imageAssetResource(index: number): ProjectResource { + return { + ...resource(`asset:img-0${index}`, 'unclassified'), + label: `img-0${index}.png`, + path: `assets/test-kit/images/img-0${index}.png`, + manifestAssetId: `img-0${index}`, + assetCategory: 'unclassified', + assetTags: [], + }; +} + +function canvasItemOf(resourceItem: ProjectResource): ResourceCanvasItem { + return { + id: resourceItem.id, + category: resourceItem.category, + subtype: resourceItem.subtype, + label: resourceItem.label, + mediaType: resourceItem.mediaType, + dependencyDepth: resourceItem.dependencyDepth, + }; +} + +type ResourceCardRect = { + resourceId: string; + category: string; + left: number; + top: number; + right: number; + bottom: number; +}; + +function resourceCardRectsInAllView({ + resources, + resourcesByCategory, + positions, + cardSizes, + allLayout, +}: { + resources: readonly ProjectResource[]; + resourcesByCategory: ReadonlyMap; + positions: ReadonlyMap; + cardSizes: ReadonlyMap; + allLayout: ResourceBookAllLayout; +}): ResourceCardRect[] { + const sceneResourcesByCategory = new Map< + ResourceBookTarget, + ProjectResource[] + >([[RESOURCE_BOOK_ALL_TARGET, [...resources]]]); + for (const [category, items] of resourcesByCategory) { + sceneResourcesByCategory.set(category, items); + } + const plan = buildResourceBookScenePlan({ + state: { + view: 'child', + category: RESOURCE_BOOK_ALL_TARGET, + phase: 'idle', + token: 1, + }, + visibleCategoryOrder: [ + RESOURCE_BOOK_ALL_TARGET, + ...RESOURCE_CANVAS_SECTION_ORDER, + ], + resourcesByCategory: sceneResourcesByCategory, + overviewRects: new Map(), + positions, + cardSizes, + visibleResourceIds: new Set(resources.map((item) => item.id)), + categoryViewCenters: new Map(), + cardsReady: true, + allLayout, + }); + return plan.flatMap((group) => + group.cards.map((card) => ({ + resourceId: card.resource.id, + category: group.category, + left: card.layout.x, + top: card.layout.y, + right: card.layout.x + card.layout.width, + bottom: card.layout.y + card.layout.height, + })), + ); +} + +/** 逐对矩形相交检查:返回的字符串自带两张卡的矩形数值,红了就能直接看是哪两张撞了。 */ +function intersectingResourceCardRects( + rects: readonly ResourceCardRect[], +): string[] { + const intersections: string[] = []; + for (let left = 0; left < rects.length; left += 1) { + for (let right = left + 1; right < rects.length; right += 1) { + const a = rects[left]!; + const b = rects[right]!; + if ( + a.left < b.right && + b.left < a.right && + a.top < b.bottom && + b.top < a.bottom + ) { + intersections.push( + `${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` + + `${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`, + ); + } + } + } + return intersections; +} + +describe('「所有资源」展开态:混合类型卡片自动布局不重叠', () => { + const documents = Array.from({ length: 12 }, (_, index) => + documentResource(index + 1), + ); + const imageSize = resourceCanvasImageCardSize({ + pixelWidth: 512, + pixelHeight: 512, + }); + const imageAssets = Array.from({ length: 12 }, (_, index) => + imageAssetResource(index + 1), + ); + const mixedResources = [...documents, ...imageAssets]; + const cardSizes = new Map([ + ...documents.map((item) => [item.id, { width: 180, height: 128 }] as const), + ...imageAssets.map((item) => [item.id, imageSize] as const), + ]); + const resourcesByCategory = new Map( + RESOURCE_CANVAS_SECTION_ORDER.map((category) => [ + category, + mixedResources.filter((item) => item.category === category), + ]), + ); + const canvasItems = mixedResources.map(canvasItemOf); + const noPositions = new Map(); + + /** + * 依赖模式在关系图就绪前的布局是空的:一张坐标都没有。此时按"所有卡都在原点"算出的分带 + * 只有单行高 —— 文档带 144(128 + 行间距)、图片带原点 192(144 + 带间距)。坐标到位后 + * 文档卡会铺到第 2、3 行(y = 168 / 336),若带几何不跟着重算,图片那一带就压在文档卡上。 + */ + function dependencyPositions() { + return new Map( + reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('mixed', 'dependency'), + canvasItems, + undefined, + cardSizes, + ).layout.positions.map((position) => [ + position.resourceId, + { x: position.x, y: position.y }, + ]), + ); + } + + it('重算第一批坐标到位后的分带,展开态里任意两张卡的矩形都不相交', () => { + const pending = resolveResourceBookAllLayout(null, { + projectId: 'mixed', + mode: 'dependency', + layoutReady: false, + categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, + resourcesByCategory, + positions: noPositions, + cardSizes, + }); + // 坐标就绪前的那一份只描述"还没有坐标",卡片此时也不渲染(见 cardsReady)。 + expect(pending.layout.bands.map((band) => band.height)).toEqual([144, 196]); + + const positions = dependencyPositions(); + const settled = resolveResourceBookAllLayout(pending, { + projectId: 'mixed', + mode: 'dependency', + layoutReady: true, + categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, + resourcesByCategory, + positions, + cardSizes, + }); + + // 硬判据:展开态里逐对矩形都不相交(红了会直接列出撞在一起的那两张卡的矩形)。 + const rects = resourceCardRectsInAllView({ + resources: mixedResources, + resourcesByCategory, + positions, + cardSizes, + allLayout: settled.layout, + }); + expect(intersectingResourceCardRects(rects)).toEqual([]); + expect(rects).toHaveLength(mixedResources.length); + + // 几何形状:文档那一带按 3 行真实坐标撑开,图片带被推到它下面,而不是挤在 144 + 48。 + const documentBand = settled.layout.bandByCategory.get('document')!; + const imageBand = settled.layout.bandByCategory.get('unclassified')!; + expect(documentBand.height).toBe(480); + expect(imageBand.originY).toBe( + documentBand.originY + documentBand.height + RESOURCE_BOOK_ALL_BAND_GAP, + ); + }); + + it('栏目页里同一栏混排不同尺寸的卡也不相交(类型 / 依赖两种模式)', () => { + const mixedSizes = new Map([ + ['variant-doc', { width: 180, height: 128 }], + [ + 'variant-square', + resourceCanvasImageCardSize({ pixelWidth: 512, pixelHeight: 512 }), + ], + [ + 'variant-tall', + resourceCanvasImageCardSize({ pixelWidth: 256, pixelHeight: 512 }), + ], + [ + 'variant-wide', + resourceCanvasImageCardSize({ pixelWidth: 1024, pixelHeight: 512 }), + ], + ]); + const variantResources = [ + { + ...resource('variant-doc'), + label: 'variant-doc.md', + path: 'assets/docs/variant-doc.md', + mediaType: 'text/markdown', + subtype: 'document', + }, + { ...resource('variant-square'), label: 'variant-square.png' }, + { ...resource('variant-tall'), label: 'variant-tall.png' }, + { ...resource('variant-wide'), label: 'variant-wide.png' }, + ]; + const variantItems = variantResources.map(canvasItemOf); + + for (const mode of ['type', 'dependency'] as const) { + const positions = new Map( + reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('variants', mode), + variantItems, + undefined, + mixedSizes, + ).layout.positions.map((position) => [ + position.resourceId, + { x: position.x, y: position.y }, + ]), + ); + const allLayout = buildResourceBookAllLayout({ + categoryOrder: ['unclassified'], + resourcesByCategory: new Map([ + ['unclassified', variantResources] as [ + ResourceBookCategory, + ProjectResource[], + ], + ]), + positions, + cardSizes: mixedSizes, + }); + const rects = resourceCardRectsInAllView({ + resources: variantResources, + resourcesByCategory: new Map([ + ['unclassified', variantResources] as [ + ResourceBookCategory, + ProjectResource[], + ], + ]), + positions, + cardSizes: mixedSizes, + allLayout, + }); + expect(rects).toHaveLength(variantResources.length); + expect(intersectingResourceCardRects(rects)).toEqual([]); + } + }); + + it('已有 sidecar 坐标(含手放)原样保留,并按下一条带让开', () => { + // 用户上一轮在「文档」栏把第一张卡拖到 y = 600(远超自动网格),sidecar 里存的就是它。 + const sidecar = createEmptyResourceCanvasLayout('mixed', 'type'); + const sidecarPositions = [ + ...sidecar.positions, + { + resourceId: documents[0]!.id, + section: 'document' as const, + x: 40, + y: 600, + manuallyPlaced: true, + }, + ]; + const positions = new Map( + reconcileResourceCanvasLayout( + { ...sidecar, positions: sidecarPositions }, + canvasItems, + undefined, + cardSizes, + ).layout.positions.map((position) => [ + position.resourceId, + { x: position.x, y: position.y }, + ]), + ); + // 手放坐标没有被协调改写。 + expect(positions.get(documents[0]!.id)).toEqual({ x: 40, y: 600 }); + + const resolved = resolveResourceBookAllLayout(null, { + projectId: 'mixed', + mode: 'type', + layoutReady: true, + categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, + resourcesByCategory, + positions, + cardSizes, + }); + const documentBand = resolved.layout.bandByCategory.get('document')!; + const imageBand = resolved.layout.bandByCategory.get('unclassified')!; + // 画出来的仍是"sidecar 局部坐标 + 带原点",即手放位置就是用户放下的那一处。 + const rects = resourceCardRectsInAllView({ + resources: mixedResources, + resourcesByCategory, + positions, + cardSizes, + allLayout: resolved.layout, + }); + const manualRect = rects.find( + (rect) => rect.resourceId === documents[0]!.id, + )!; + expect(manualRect.left).toBe(40 + documentBand.originX); + expect(manualRect.top).toBe(600 + documentBand.originY); + // 带高按被拖到 600 的那张卡撑开,图片带整体让到它下面。 + expect(manualRect.bottom).toBeLessThanOrEqual( + documentBand.originY + documentBand.height, + ); + expect(imageBand.originY).toBeGreaterThanOrEqual( + manualRect.bottom + RESOURCE_BOOK_ALL_BAND_GAP, + ); + expect(intersectingResourceCardRects(rects)).toEqual([]); + }); +}); + +describe('resourceBookAllLayoutKey', () => { + const layoutInput = { + projectId: 'key-project', + mode: 'dependency' as const, + categoryOrder: ['document', 'unclassified'] as const, + resourcesByCategory: new Map([ + ['document', [documentResource(1)]], + ['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]], + ]), + cardSizes: new Map([ + ['agent-result:design:run-1', { width: 180, height: 128 }], + ['asset:img-01', { width: 180, height: 180 }], + ]), + }; + const settledPositions = new Map([ + ['agent-result:design:run-1', { x: 0, y: 0 }], + ['asset:img-01', { x: 0, y: 0 }], + ]); + + it('keeps the key when only the coordinates move, so dragging never re-stacks the bands', () => { + const before = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: settledPositions, + }); + const dragged = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: new Map([ + ['agent-result:design:run-1', { x: 900, y: 1_200 }], + ['asset:img-01', { x: 0, y: 0 }], + ]), + }); + + expect(dragged).toBe(before); + }); + + it('changes the key while the layout is not settled yet, so the first real bands are rebuilt', () => { + const pending = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: false, + positions: new Map(), + }); + const settled = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: settledPositions, + }); + // 坐标缺一张也仍是 pending:缺坐标时算出来的带高只代表"还没布局",不能当最终几何用。 + const partial = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: new Map([['agent-result:design:run-1', { x: 0, y: 0 }]]), + }); + + expect(pending).not.toBe(settled); + expect(partial).toBe(pending); + }); + + it('changes the key when real card sizes or the sort mode change', () => { + const base = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: settledPositions, + }); + const measuredLater = resourceBookAllLayoutKey({ + ...layoutInput, + layoutReady: true, + positions: settledPositions, + cardSizes: new Map([ + ['agent-result:design:run-1', { width: 180, height: 128 }], + ['asset:img-01', { width: 220, height: 180 }], + ]), + }); + const typeMode = resourceBookAllLayoutKey({ + ...layoutInput, + mode: 'type', + layoutReady: true, + positions: settledPositions, + }); + + expect(measuredLater).not.toBe(base); + expect(typeMode).not.toBe(base); + }); +});