修复「所有资源」展开态文档卡与图片卡初始自动重叠
- 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:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
This commit is contained in:
@@ -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<ResourceBookAllLayoutCache | null>(
|
||||
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,
|
||||
]);
|
||||
/**
|
||||
|
||||
@@ -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<ResourceBookCategory, ProjectResource[]>;
|
||||
positions: ReadonlyMap<string, { x: number; y: number }>;
|
||||
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
||||
};
|
||||
|
||||
export function buildResourceBookAllLayout({
|
||||
categoryOrder,
|
||||
resourcesByCategory,
|
||||
positions,
|
||||
cardSizes,
|
||||
}: {
|
||||
categoryOrder: readonly ResourceBookCategory[];
|
||||
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
|
||||
positions: ReadonlyMap<string, { x: number; y: number }>;
|
||||
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
||||
}): ResourceBookAllLayout {
|
||||
}: ResourceBookAllLayoutInput): ResourceBookAllLayout {
|
||||
const bands: ResourceBookAllBand[] = [];
|
||||
const bandByCategory = new Map<ResourceBookCategory, ResourceBookAllBand>();
|
||||
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<string, number>();
|
||||
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),
|
||||
|
||||
@@ -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 (
|
||||
<ProjectDevelopmentView
|
||||
projectName={manifest.name}
|
||||
projectPath={projectPath}
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
supervisor={<div>Supervisor</div>}
|
||||
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(<MixedCategoryWorkbench />);
|
||||
|
||||
// 依赖排序:坐标等关系图读回(这就是用户看到的"一开始")。
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ResourceBookCategory, ProjectResource[]>;
|
||||
positions: ReadonlyMap<string, { x: number; y: number }>;
|
||||
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
||||
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<string, ResourceCanvasCardSize>([
|
||||
...documents.map((item) => [item.id, { width: 180, height: 128 }] as const),
|
||||
...imageAssets.map((item) => [item.id, imageSize] as const),
|
||||
]);
|
||||
const resourcesByCategory = new Map<ResourceBookCategory, ProjectResource[]>(
|
||||
RESOURCE_CANVAS_SECTION_ORDER.map((category) => [
|
||||
category,
|
||||
mixedResources.filter((item) => item.category === category),
|
||||
]),
|
||||
);
|
||||
const canvasItems = mixedResources.map(canvasItemOf);
|
||||
const noPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
/**
|
||||
* 依赖模式在关系图就绪前的布局是空的:一张坐标都没有。此时按"所有卡都在原点"算出的分带
|
||||
* 只有单行高 —— 文档带 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<string, ResourceCanvasCardSize>([
|
||||
['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<ResourceBookCategory, ProjectResource[]>([
|
||||
['document', [documentResource(1)]],
|
||||
['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]],
|
||||
]),
|
||||
cardSizes: new Map<string, ResourceCanvasCardSize>([
|
||||
['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<string, ResourceCanvasCardSize>([
|
||||
['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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user