1cc9a23815
- 渲染层新增 `RESOURCE_BOOK_ALL_TARGET = 'all'` 特殊项:跨端契约 `ProjectResourceCanvasCategory` 不加 `'all'`,sidecar schema 不升版,落盘布局的 `section` 也不会出现它 - 资源总览第 0 张卡即「所有资源」:排在全部栏目卡之前,复用同一套缩略图类名/圆角/底色/阴影与栏目标题栏组件,不新增卡片样式 - 该卡的计数徽标取全量资源数,口径等于各栏目计数之和(同一份画布投影,不另算一套) - 画本场景的栏目顺序与资源分组各多一个渲染层入口;`buildResourceBookScenePlan` 对特殊项只出计数与标题栏,不铺卡片,避免同一张资源卡在总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车) - 新增「所有资源」页:栏目大纲 + 收起资源按钮 + 按栏目分组的滚动卡片网格;卡片复用栏目页同一颗渲染器与卡宽高,容器与网格样式补齐在 styles.css - 该页的卡片显式关掉拖拽(卡片坐标不属于任何栏目画布,写回会污染布局 sidecar),保留选中/播放/菜单交互,并把光标与 `touch-action: pan-y` 交给滚动容器 - 栏目大纲抽成 `ResourceBookOutlineNav`,栏目页与全部资源页共用同一条入口带;渲染出的 DOM 与抽取前一致 - `renderResourceBookCard` 增加可选 `draggable` 参数,`ResourceCard` 的三个移动端指针回调改为可选 - 断言:总览存在「所有资源」卡且为第 0 张、计数等于全量资源数且等于各栏目计数之和、点它进入的页面按栏目分组且每组都挂真实资源卡、场景计划里特殊项不出卡片且整份计划无重复卡片键、全部资源页分组只保留非空栏目且保持总览顺序 - 既有「N 项」徽标断言从全文档文本查找收窄到 character 栏自己的标题栏(新增第 0 张卡后同文案会撞车,收窄而非放宽)
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildResourceBookAllResourcesSections,
|
|
buildResourceBookScenePlan,
|
|
groupResourceBookStacks,
|
|
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
|
resourceBookOverviewCardLayout,
|
|
type ResourceBookOverviewRect,
|
|
resourceBookSceneCardKey,
|
|
selectResourceBookOverviewCards,
|
|
} from '../src/view/project-development/resourceBookLayout';
|
|
import {
|
|
RESOURCE_BOOK_ALL_TARGET,
|
|
type ResourceBookTarget,
|
|
} from '../src/view/project-development/resourceBookModel';
|
|
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
|
|
|
function resource(
|
|
id: string,
|
|
category: ProjectResource['category'] = 'unclassified',
|
|
): ProjectResource {
|
|
return {
|
|
id,
|
|
category,
|
|
subtype: 'image',
|
|
label: id,
|
|
path: `assets/${id}.png`,
|
|
mediaType: 'image/png',
|
|
sourceLabel: '测试',
|
|
taskTitle: null,
|
|
manifestAssetId: id,
|
|
producerTaskId: null,
|
|
externalResourceId: null,
|
|
referenceResourceIds: [],
|
|
dependencies: [],
|
|
dependencyDepth: 0,
|
|
};
|
|
}
|
|
|
|
const rect = { left: 100, top: 200, width: 280, height: 210 };
|
|
|
|
describe('resourceBookOverviewCardLayout', () => {
|
|
it('keeps the stack tilt bounded however many resources share one type', () => {
|
|
const rotations = [0, 1, 2, 3, 12, 59].map(
|
|
(stackIndex) =>
|
|
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 })
|
|
.rotation,
|
|
);
|
|
|
|
for (const rotation of rotations) {
|
|
expect(Math.abs(rotation)).toBeLessThanOrEqual(3);
|
|
}
|
|
expect(rotations[0]).toBe(0.5);
|
|
expect(rotations.at(-1)).toBe(rotations[2]);
|
|
});
|
|
|
|
it('stops offsetting deeper cards once the visible stack depth is reached', () => {
|
|
const positions = [0, 1, 2, 3, 59].map(
|
|
(stackIndex) =>
|
|
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 }).y,
|
|
);
|
|
|
|
expect(positions[0]).toBe(rect.top + 58);
|
|
expect(positions[1]).toBe(rect.top + 63);
|
|
expect(positions[2]).toBe(rect.top + 68);
|
|
expect(positions.at(-1)).toBe(positions[2]);
|
|
});
|
|
|
|
it('limits how many cards the overview renders per type stack', () => {
|
|
expect(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT).toBe(3);
|
|
});
|
|
|
|
describe('selectResourceBookOverviewCards', () => {
|
|
const cards = ['a', 'b', 'c', 'd'].map(resource);
|
|
const positions = new Map([
|
|
['a', { x: 0, y: 0 }],
|
|
['b', { x: 1_000, y: 0 }],
|
|
['c', { x: 2_000, y: 0 }],
|
|
['d', { x: 3_000, y: 0 }],
|
|
]);
|
|
const cardSizes = new Map<string, { width: number; height: number }>();
|
|
|
|
it('keeps the first visible cards when the view center is unknown', () => {
|
|
expect(
|
|
selectResourceBookOverviewCards({
|
|
resources: cards,
|
|
visibleResourceIds: new Set(['a', 'b', 'c', 'd']),
|
|
positions,
|
|
cardSizes,
|
|
viewCenter: undefined,
|
|
}).map((item) => item.id),
|
|
).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('prefers the cards nearest the current view center', () => {
|
|
expect(
|
|
selectResourceBookOverviewCards({
|
|
resources: cards,
|
|
visibleResourceIds: new Set(['a', 'b', 'c', 'd']),
|
|
positions,
|
|
cardSizes,
|
|
viewCenter: { x: 3_000, y: 64 },
|
|
}).map((item) => item.id),
|
|
).toEqual(['d', 'c', 'b']);
|
|
});
|
|
|
|
it('never surfaces cards hidden by the current filter', () => {
|
|
expect(
|
|
selectResourceBookOverviewCards({
|
|
resources: cards,
|
|
visibleResourceIds: new Set(['a', 'c', 'd']),
|
|
positions,
|
|
cardSizes,
|
|
viewCenter: { x: 1_000, y: 64 },
|
|
}).map((item) => item.id),
|
|
).toEqual(['a', 'c', 'd']);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('groupResourceBookStacks', () => {
|
|
it('keeps resources of one type in a single stack', () => {
|
|
const stacks = groupResourceBookStacks([
|
|
resource('overview-art-0'),
|
|
resource('overview-art-1'),
|
|
resource('overview-art-2'),
|
|
resource('overview-art-3'),
|
|
]);
|
|
|
|
expect(stacks).toHaveLength(1);
|
|
expect(stacks[0]?.[1]).toHaveLength(4);
|
|
});
|
|
});
|
|
|
|
describe('buildResourceBookScenePlan', () => {
|
|
const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource);
|
|
const positions = new Map(
|
|
resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]),
|
|
);
|
|
const cardSizes = new Map(
|
|
resources.map((item) => [item.id, { width: 180, height: 128 }]),
|
|
);
|
|
const base = {
|
|
visibleCategoryOrder: ['unclassified'] as const,
|
|
resourcesByCategory: new Map([['unclassified', resources]]),
|
|
overviewRects: new Map([['unclassified', rect]]),
|
|
positions,
|
|
cardSizes,
|
|
visibleResourceIds: new Set(resources.map((item) => item.id)),
|
|
categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]),
|
|
cardsReady: true,
|
|
};
|
|
|
|
it('renders the bounded overview stack on the steady main canvas', () => {
|
|
const plan = buildResourceBookScenePlan({
|
|
...base,
|
|
state: {
|
|
view: 'main',
|
|
category: null,
|
|
phase: 'idle',
|
|
token: 0,
|
|
},
|
|
});
|
|
|
|
expect(plan).toHaveLength(1);
|
|
expect(plan[0]!.cards).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
|
|
expect(plan[0]!.cards[0]!.presentation).toBe('overview');
|
|
expect(plan[0]!.cards[0]!.stackCount).toBe(
|
|
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
|
);
|
|
expect(plan[0]!.cards[0]!.layout).toMatchObject({
|
|
x: rect.left + 18,
|
|
y: rect.top + 58,
|
|
width: 92,
|
|
height: 64,
|
|
});
|
|
expect(plan[0]!.titlebarActive).toBe(false);
|
|
expect(plan[0]!.titlebarOpacity).toBe(1);
|
|
expect(plan[0]!.resourceCount).toBe(4);
|
|
});
|
|
|
|
it('keeps other categories mounted for a fade-out while entering', () => {
|
|
const plan = buildResourceBookScenePlan({
|
|
...base,
|
|
visibleCategoryOrder: ['unclassified', 'scene'],
|
|
resourcesByCategory: new Map([
|
|
['unclassified', resources],
|
|
['scene', [resource('code-0')]],
|
|
]),
|
|
overviewRects: new Map([
|
|
['unclassified', rect],
|
|
['scene', { ...rect, left: 500 }],
|
|
]),
|
|
state: {
|
|
view: 'child',
|
|
category: 'unclassified',
|
|
phase: 'entering',
|
|
token: 1,
|
|
},
|
|
});
|
|
|
|
const artGroup = plan.find((group) => group.category === 'unclassified')!;
|
|
const codeGroup = plan.find((group) => group.category === 'scene')!;
|
|
expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe(
|
|
true,
|
|
);
|
|
expect(artGroup.cards).toHaveLength(4);
|
|
expect(artGroup.titlebarActive).toBe(true);
|
|
// 其他栏目留在 DOM 里淡出,而不是直接卸载。
|
|
expect(
|
|
codeGroup.cards.every((card) => card.presentation === 'overview'),
|
|
).toBe(true);
|
|
expect(codeGroup.titlebarActive).toBe(false);
|
|
expect(codeGroup.titlebarOpacity).toBe(0);
|
|
});
|
|
|
|
it('marks cards that do not return to the stack as exiting', () => {
|
|
const plan = buildResourceBookScenePlan({
|
|
...base,
|
|
state: {
|
|
view: 'main',
|
|
category: null,
|
|
phase: 'returning-main',
|
|
token: 2,
|
|
},
|
|
exitingCategory: 'unclassified',
|
|
});
|
|
|
|
const cards = plan[0]!.cards;
|
|
expect(
|
|
cards.filter((card) => card.presentation === 'overview'),
|
|
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
|
|
const exiting = cards.filter((card) => card.presentation === 'exiting');
|
|
expect(exiting).toHaveLength(1);
|
|
// 淡出节点按子画布布局渲染,位置由 FLIP 钉在 First。
|
|
expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x);
|
|
});
|
|
|
|
it('keeps search-driven removals mounted without duplicating keys', () => {
|
|
const plan = buildResourceBookScenePlan({
|
|
...base,
|
|
state: { view: 'main', category: null, phase: 'idle', token: 0 },
|
|
exitingCards: [
|
|
{
|
|
key: resourceBookSceneCardKey('art-3'),
|
|
category: 'unclassified',
|
|
resource: resources[3]!,
|
|
presentation: 'exiting',
|
|
stackIndex: 0,
|
|
stackColumn: 0,
|
|
stackCount: 1,
|
|
layout: {
|
|
x: 0,
|
|
y: 0,
|
|
width: 180,
|
|
height: 128,
|
|
dragX: 0,
|
|
dragY: 0,
|
|
rotation: 0,
|
|
},
|
|
},
|
|
{
|
|
key: resourceBookSceneCardKey('art-0'),
|
|
category: 'unclassified',
|
|
resource: resources[0]!,
|
|
presentation: 'exiting',
|
|
stackIndex: 0,
|
|
stackColumn: 0,
|
|
stackCount: 1,
|
|
layout: {
|
|
x: 0,
|
|
y: 0,
|
|
width: 180,
|
|
height: 128,
|
|
dragX: 0,
|
|
dragY: 0,
|
|
rotation: 0,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const keys = plan[0]!.cards.map((card) => card.key);
|
|
expect(keys).toContain(resourceBookSceneCardKey('art-3'));
|
|
expect(
|
|
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
/**
|
|
* 资源总览第 0 张「所有资源」卡走的是同一条渲染管线:它有栏目标题栏(计数即全量
|
|
* 资源数),但正文不铺卡片——同一张资源卡在各栏目摞里已经渲染过,再铺一遍会让
|
|
* `data-resource-card-id` 与 FLIP 的 `card:*` 键在同一份计划里出现两次。
|
|
*/
|
|
it('reports the full count for the all-resources tile without duplicating cards', () => {
|
|
const documentResource = resource('design-0', 'document');
|
|
const allResources = [...resources, documentResource];
|
|
const allPositions = new Map([
|
|
...positions,
|
|
[documentResource.id, { x: 900, y: 0 }],
|
|
]);
|
|
const allCardSizes = new Map([
|
|
...cardSizes,
|
|
[documentResource.id, { width: 180, height: 128 }],
|
|
]);
|
|
const plan = buildResourceBookScenePlan({
|
|
...base,
|
|
visibleCategoryOrder: [
|
|
RESOURCE_BOOK_ALL_TARGET,
|
|
'unclassified',
|
|
'document',
|
|
],
|
|
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
|
|
[RESOURCE_BOOK_ALL_TARGET, allResources],
|
|
['unclassified', resources],
|
|
['document', [documentResource]],
|
|
]),
|
|
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>([
|
|
[RESOURCE_BOOK_ALL_TARGET, { ...rect, left: 0, top: 0 }],
|
|
['unclassified', rect],
|
|
['document', { ...rect, left: 500 }],
|
|
]),
|
|
positions: allPositions,
|
|
cardSizes: allCardSizes,
|
|
visibleResourceIds: new Set(allResources.map((item) => item.id)),
|
|
categoryViewCenters: new Map([
|
|
['unclassified', { x: 0, y: 64 }],
|
|
['document', { x: 900, y: 64 }],
|
|
]),
|
|
state: { view: 'main', category: null, phase: 'idle', token: 0 },
|
|
});
|
|
|
|
const allGroup = plan.find(
|
|
(group) => group.category === RESOURCE_BOOK_ALL_TARGET,
|
|
)!;
|
|
expect(allGroup.resourceCount).toBe(allResources.length);
|
|
expect(allGroup.cards).toHaveLength(0);
|
|
expect(allGroup.titlebarActive).toBe(false);
|
|
expect(allGroup.titlebarOpacity).toBe(1);
|
|
|
|
// 各栏目自己的摞照旧,且整份计划里没有任何一张资源卡被挂两次。
|
|
const cardKeys = plan
|
|
.flatMap((group) => group.cards)
|
|
.map((card) => card.key);
|
|
expect(new Set(cardKeys).size).toBe(cardKeys.length);
|
|
expect(
|
|
plan
|
|
.find((group) => group.category === 'document')!
|
|
.cards.map((card) => card.key),
|
|
).toEqual([resourceBookSceneCardKey('design-0')]);
|
|
expect(
|
|
plan.find((group) => group.category === 'unclassified')!.cards.length,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('buildResourceBookAllResourcesSections', () => {
|
|
it('keeps the overview section order and drops empty sections', () => {
|
|
const sections = buildResourceBookAllResourcesSections({
|
|
categoryOrder: ['ui-interaction', 'character', 'audio'],
|
|
resourcesByCategory: new Map([
|
|
['ui-interaction', [resource('ui-0', 'ui-interaction')]],
|
|
['character', []],
|
|
['audio', [resource('bgm-0', 'audio'), resource('bgm-1', 'audio')]],
|
|
]),
|
|
});
|
|
|
|
expect(
|
|
sections.map((section) => [section.category, section.resources.length]),
|
|
).toEqual([
|
|
['ui-interaction', 1],
|
|
['audio', 2],
|
|
]);
|
|
});
|
|
});
|