资源总览新增「所有资源」汇总卡与全部资源页
- 渲染层新增 `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 张卡后同文案会撞车,收窄而非放宽)
This commit is contained in:
@@ -6650,6 +6650,65 @@ iframe.preview-frame {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 「所有资源」页。
|
||||
*
|
||||
* 资源总览第 0 张卡打开的汇总页:左侧沿用栏目大纲,正文按栏目分组的滚动卡片网格。
|
||||
* 整页自成一层(`z-index: 21`):栏目态的画本场景自带一层不透明底纹(`z-index: 20`),
|
||||
* 只在 `z-index: auto` 上铺会被它盖住;同时它不落在 `.game-resource-canvas` 里,
|
||||
* 那层挂着画布平移/框选的指针与滚轮处理器,会把这里的滚动吃掉。
|
||||
*
|
||||
* 卡片本体仍由栏目页同一颗卡片渲染器产出(尺寸取画布默认卡宽高),
|
||||
* 因此这一页只补齐容器与网格,不新增卡片样式。
|
||||
*/
|
||||
.game-resource-all-resources-host {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
inset: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background-color: #fffdfa;
|
||||
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.game-resource-all-scroll {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
padding: 2px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.game-resource-all-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ecdfd8;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 253 250 / 82%);
|
||||
}
|
||||
|
||||
.game-resource-all-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 180px);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.game-resource-all-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 只浏览的页面不接拖拽:光标如实反映「点选 / 播放」,并且把纵向触摸手势交还给滚动容器
|
||||
(`.game-resource-card` 常态是 `touch-action: none`,那是画布拖卡需要的)。 */
|
||||
.game-resource-all-grid .game-resource-card {
|
||||
cursor: pointer;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.game-resource-section-stack {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,10 @@ import type {
|
||||
ResourceBookCardPresentation,
|
||||
ResourceBookCategory,
|
||||
ResourceBookState,
|
||||
ResourceBookTarget,
|
||||
} from './resourceBookModel';
|
||||
import {
|
||||
isResourceBookAllTarget,
|
||||
resourceBookCategoryCardPresentation,
|
||||
resourceBookShowsExitingCards,
|
||||
} from './resourceBookModel';
|
||||
@@ -55,6 +57,29 @@ export function groupResourceBookStacks(
|
||||
);
|
||||
}
|
||||
|
||||
export type ResourceBookAllResourcesSection = {
|
||||
category: ResourceBookCategory;
|
||||
resources: ProjectResource[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 「所有资源」页的分组分页口径:只保留有资源的栏目,并沿用总览的栏目顺序。
|
||||
*
|
||||
* 空栏目不占一屏,用户在全部资源页看到的每一屏都能对上资源总览里的某一栏。
|
||||
*/
|
||||
export function buildResourceBookAllResourcesSections({
|
||||
categoryOrder,
|
||||
resourcesByCategory,
|
||||
}: {
|
||||
categoryOrder: readonly ResourceBookCategory[];
|
||||
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
|
||||
}): ResourceBookAllResourcesSection[] {
|
||||
return categoryOrder.flatMap((category) => {
|
||||
const resources = resourcesByCategory.get(category) ?? [];
|
||||
return resources.length > 0 ? [{ category, resources }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function groupResourceBookResourcesByCategory(
|
||||
resources: readonly ProjectResource[],
|
||||
) {
|
||||
@@ -174,13 +199,13 @@ export function resourceBookSceneCardKey(resourceId: string) {
|
||||
return `card:${resourceId}`;
|
||||
}
|
||||
|
||||
export function resourceBookSceneTitleKey(category: ResourceBookCategory) {
|
||||
export function resourceBookSceneTitleKey(category: ResourceBookTarget) {
|
||||
return `title:${category}`;
|
||||
}
|
||||
|
||||
export type ResourceBookSceneCardPlan = {
|
||||
key: string;
|
||||
category: ResourceBookCategory;
|
||||
category: ResourceBookTarget;
|
||||
resource: ProjectResource;
|
||||
presentation: ResourceBookCardPresentation;
|
||||
stackIndex: number;
|
||||
@@ -191,7 +216,7 @@ export type ResourceBookSceneCardPlan = {
|
||||
};
|
||||
|
||||
export type ResourceBookSceneCategoryPlan = {
|
||||
category: ResourceBookCategory;
|
||||
category: ResourceBookTarget;
|
||||
resourceCount: number;
|
||||
titlebarActive: boolean;
|
||||
titlebarOpacity: number;
|
||||
@@ -205,6 +230,7 @@ export type ResourceBookSceneCategoryPlan = {
|
||||
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
|
||||
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
|
||||
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
|
||||
* - 「所有资源」组只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载。
|
||||
*/
|
||||
export function buildResourceBookScenePlan({
|
||||
state,
|
||||
@@ -220,18 +246,18 @@ export function buildResourceBookScenePlan({
|
||||
exitingCards = [],
|
||||
}: {
|
||||
state: ResourceBookState;
|
||||
visibleCategoryOrder: readonly ResourceBookCategory[];
|
||||
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
|
||||
overviewRects: ReadonlyMap<ResourceBookCategory, ResourceBookOverviewRect>;
|
||||
visibleCategoryOrder: readonly ResourceBookTarget[];
|
||||
resourcesByCategory: ReadonlyMap<ResourceBookTarget, ProjectResource[]>;
|
||||
overviewRects: ReadonlyMap<ResourceBookTarget, ResourceBookOverviewRect>;
|
||||
positions: ReadonlyMap<string, { x: number; y: number }>;
|
||||
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
categoryViewCenters: ReadonlyMap<
|
||||
ResourceBookCategory,
|
||||
ResourceBookTarget,
|
||||
{ x: number; y: number }
|
||||
>;
|
||||
cardsReady: boolean;
|
||||
exitingCategory?: ResourceBookCategory | null;
|
||||
exitingCategory?: ResourceBookTarget | null;
|
||||
exitingCards?: readonly ResourceBookSceneCardPlan[];
|
||||
}): ResourceBookSceneCategoryPlan[] {
|
||||
return visibleCategoryOrder.map((category) => {
|
||||
@@ -241,7 +267,11 @@ export function buildResourceBookScenePlan({
|
||||
const categoryResources = resourcesByCategory.get(category) ?? [];
|
||||
const stacks = groupResourceBookStacks(categoryResources);
|
||||
const childLayout = presentation === 'child';
|
||||
if (cardsReady && presentation) {
|
||||
// 「所有资源」的卡片在各栏目摞里已经渲染过;这里再铺一遍会让同一张资源卡在
|
||||
// 总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)。
|
||||
const rendersCards =
|
||||
cardsReady && presentation && !isResourceBookAllTarget(category);
|
||||
if (rendersCards) {
|
||||
stacks.forEach(([, items], stackColumn) => {
|
||||
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
|
||||
const stackItems = childLayout
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import type { ProjectResourceCategory } from './resourceProjectionModel';
|
||||
|
||||
export type ResourceBookCategory = ProjectResourceCategory;
|
||||
|
||||
/**
|
||||
* 「所有资源」是渲染层的特殊项,不是真实资源栏目。
|
||||
*
|
||||
* 它只存在于画本(资源总览)自己的状态与渲染里:跨端契约的
|
||||
* `ProjectResourceCanvasCategory` 不新增 `'all'`,落盘布局的 `section` 也不会出现它,
|
||||
* 因此既不需要改 sidecar schema,也不会污染写入侧的分区口径。
|
||||
*/
|
||||
export const RESOURCE_BOOK_ALL_TARGET = 'all' as const;
|
||||
|
||||
/** 画本可以打开的目标:真实栏目,或渲染层的「所有资源」。 */
|
||||
export type ResourceBookTarget =
|
||||
| ResourceBookCategory
|
||||
| typeof RESOURCE_BOOK_ALL_TARGET;
|
||||
|
||||
export function isResourceBookAllTarget(
|
||||
value: unknown,
|
||||
): value is typeof RESOURCE_BOOK_ALL_TARGET {
|
||||
return value === RESOURCE_BOOK_ALL_TARGET;
|
||||
}
|
||||
|
||||
export type ResourceBookPhase = 'idle' | 'entering' | 'returning-main';
|
||||
|
||||
export type ResourceBookState = {
|
||||
view: 'main' | 'child';
|
||||
category: ResourceBookCategory | null;
|
||||
category: ResourceBookTarget | null;
|
||||
phase: ResourceBookPhase;
|
||||
token: number;
|
||||
};
|
||||
|
||||
export type ResourceBookAction =
|
||||
| { type: 'open-category'; category: ResourceBookCategory; token: number }
|
||||
| { type: 'open-category'; category: ResourceBookTarget; token: number }
|
||||
| { type: 'finish-transition'; token: number }
|
||||
| { type: 'return-to-main'; token: number }
|
||||
| { type: 'reset'; token: number };
|
||||
@@ -91,11 +112,16 @@ export type ResourceBookCardPresentation = 'child' | 'overview' | 'exiting';
|
||||
|
||||
export function resourceBookCategoryCardPresentation(
|
||||
state: ResourceBookState,
|
||||
category: ResourceBookCategory,
|
||||
category: ResourceBookTarget,
|
||||
): Exclude<ResourceBookCardPresentation, 'exiting'> | null {
|
||||
if (state.view === 'main') {
|
||||
return 'overview';
|
||||
}
|
||||
// 「所有资源」页自己按栏目渲染全部资源卡,画本场景不再复制一份;
|
||||
// 否则同一张资源卡会在总览/全部资源页同时挂载(DOM 查询与 FLIP 键都会撞车)。
|
||||
if (isResourceBookAllTarget(state.category)) {
|
||||
return null;
|
||||
}
|
||||
if (category === state.category) {
|
||||
return 'child';
|
||||
}
|
||||
|
||||
@@ -1130,7 +1130,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT),
|
||||
);
|
||||
expect(screen.getByText('5 项')).not.toBeNull();
|
||||
// 「N 项」徽标现在同样出现在总览第 0 张「所有资源」卡上(全量计数),
|
||||
// 因此这里必须钉到 character 栏自己的标题栏,而不是按可见文本全文档查找。
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-book-scene-titlebar[data-resource-book-category="character"] small',
|
||||
)?.textContent,
|
||||
).toBe('5 项');
|
||||
const overviewCardLayers = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-scene-card[data-resource-book-category="character"]',
|
||||
@@ -8599,4 +8605,204 @@ export function registerProjectAgentStatusTests() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 资源总览第 0 张「所有资源」卡。
|
||||
*
|
||||
* 三件事必须同时成立:它是总览的第一张、计数就是全量资源数(与各栏目计数之和同口径)、
|
||||
* 点它进入的页面里能同时看到来自多个栏目的资源。只断言「页面上有全部资源入口」会让
|
||||
* 一个只展示单栏目的实现也变绿,所以第三条按栏目分组逐组钉卡片本体。
|
||||
*/
|
||||
it('keeps an all-resources tile first in the overview and opens every section', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-all-resources',
|
||||
'全部资源项目',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'all-resources-document',
|
||||
kind: 'design-document',
|
||||
mediaType: 'text/markdown',
|
||||
localPath: 'memory/all-resources.md',
|
||||
source: { kind: 'generated', taskId: 'design-foundation' },
|
||||
category: 'document',
|
||||
},
|
||||
{
|
||||
id: 'all-resources-character',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/all-resources-character.png',
|
||||
source: { kind: 'generated', taskId: 'art-asset-plan' },
|
||||
category: 'character',
|
||||
},
|
||||
{
|
||||
id: 'all-resources-audio',
|
||||
kind: 'background-music',
|
||||
mediaType: 'audio/mpeg',
|
||||
localPath: 'assets/all-resources-audio.mp3',
|
||||
source: { kind: 'generated', taskId: 'audio-asset-plan' },
|
||||
category: 'audio',
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'all-resources-version',
|
||||
parentVersionId: null,
|
||||
projectRevision: 1,
|
||||
resourceBindings: [],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
const allResources = projectResourcesFromReadModels(manifest, [], []);
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs([]);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: args?.expectedProjectId,
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: layoutRevision,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: args?.expectedProjectId,
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: '/tmp/workbench-all-resources',
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const readCountBadge = (selector: string) => {
|
||||
const badge = document.querySelector<HTMLElement>(
|
||||
`${selector} .game-resource-book-titlebar-meta small`,
|
||||
);
|
||||
const match = /^(\d+) 项$/u.exec(badge?.textContent ?? '');
|
||||
expect(badge, `${selector} 的计数徽标缺失`).not.toBeNull();
|
||||
expect(match, `${selector} 的计数徽标不是「N 项」`).not.toBeNull();
|
||||
return Number(match![1]);
|
||||
};
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
|
||||
),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
// 1) 总览里存在「所有资源」卡,并且排在栏目卡之前(第 0 张)。
|
||||
const overviewGrid = document.querySelector<HTMLElement>(
|
||||
'.game-resource-book-main-grid',
|
||||
);
|
||||
expect(overviewGrid).not.toBeNull();
|
||||
const overviewTiles = Array.from(
|
||||
overviewGrid!.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-thumbnail',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
overviewTiles.map((tile) => tile.dataset.resourceBookCategory),
|
||||
).toEqual([
|
||||
'all',
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'audio',
|
||||
'document',
|
||||
'unclassified',
|
||||
'version',
|
||||
]);
|
||||
expect(screen.getByRole('button', { name: '打开所有资源' })).toBe(
|
||||
overviewTiles[0],
|
||||
);
|
||||
|
||||
// 2) 它的计数等于全量资源数,并且与各栏目计数之和同一口径(不另算一套)。
|
||||
const allResourcesCount = readCountBadge(
|
||||
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
|
||||
);
|
||||
expect(allResourcesCount).toBe(allResources.length);
|
||||
const perCategoryCounts = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-scene-titlebar[data-resource-book-category]',
|
||||
),
|
||||
)
|
||||
.filter((titlebar) => titlebar.dataset.resourceBookCategory !== 'all')
|
||||
.map((titlebar) =>
|
||||
readCountBadge(
|
||||
`.game-resource-book-scene-titlebar[data-resource-book-category="${titlebar.dataset.resourceBookCategory}"]`,
|
||||
),
|
||||
);
|
||||
expect(perCategoryCounts.reduce((total, count) => total + count, 0)).toBe(
|
||||
allResourcesCount,
|
||||
);
|
||||
|
||||
// 3) 点它进入的视图包含来自多个栏目的资源,而不是某一个栏目。
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('[data-resource-book-all-host="true"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
const expectSortedCategories = (categories: string[]) =>
|
||||
[...categories].sort();
|
||||
const allSections = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-all-section[data-resource-book-category]',
|
||||
),
|
||||
);
|
||||
const expectedSections = expectSortedCategories(
|
||||
Array.from(new Set(allResources.map((resource) => resource.category))),
|
||||
);
|
||||
expect(
|
||||
expectSortedCategories(
|
||||
allSections.map((section) => section.dataset.resourceBookCategory!),
|
||||
),
|
||||
).toEqual(expectedSections);
|
||||
expect(allSections.length).toBeGreaterThan(1);
|
||||
for (const section of allSections) {
|
||||
expect(
|
||||
section.querySelectorAll('.game-resource-card').length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
// 汇总页自己的标题栏也用同一份全量计数。
|
||||
expect(readCountBadge('.game-resource-all-page')).toBe(allResourcesCount);
|
||||
// 汇总页复用栏目大纲:第 0 项是「所有资源」,并标成当前页。
|
||||
const allOutline = screen.getByLabelText('资源栏目大纲');
|
||||
expect(
|
||||
allOutline.querySelector('[aria-current="page"]')?.textContent?.trim(),
|
||||
).toBe('所有资源');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
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): ProjectResource {
|
||||
function resource(
|
||||
id: string,
|
||||
category: ProjectResource['category'] = 'unclassified',
|
||||
): ProjectResource {
|
||||
return {
|
||||
id,
|
||||
category: 'unclassified',
|
||||
category,
|
||||
subtype: 'image',
|
||||
label: id,
|
||||
path: `assets/${id}.png`,
|
||||
@@ -278,4 +287,90 @@ describe('buildResourceBookScenePlan', () => {
|
||||
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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
initialResourceBookState,
|
||||
isResourceBookAllTarget,
|
||||
RESOURCE_BOOK_ALL_TARGET,
|
||||
resourceBookCategoryCardPresentation,
|
||||
resourceBookReducer,
|
||||
resourceBookSceneCategory,
|
||||
@@ -162,3 +164,54 @@ describe('resource book card presentation', () => {
|
||||
expect(resourceBookShowsExitingCards(initialResourceBookState)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource book all-resources target', () => {
|
||||
it('takes the overview stack as the first tile on the main canvas', () => {
|
||||
expect(
|
||||
resourceBookCategoryCardPresentation(
|
||||
initialResourceBookState,
|
||||
RESOURCE_BOOK_ALL_TARGET,
|
||||
),
|
||||
).toBe('overview');
|
||||
});
|
||||
|
||||
it('lets the all-resources page render every category without a second copy', () => {
|
||||
const all = resourceBookReducer(
|
||||
resourceBookReducer(initialResourceBookState, {
|
||||
type: 'open-category',
|
||||
category: RESOURCE_BOOK_ALL_TARGET,
|
||||
token: 1,
|
||||
}),
|
||||
{ type: 'finish-transition', token: 1 },
|
||||
);
|
||||
|
||||
expect(all).toMatchObject({
|
||||
view: 'child',
|
||||
category: RESOURCE_BOOK_ALL_TARGET,
|
||||
phase: 'idle',
|
||||
});
|
||||
expect(resourceBookSceneCategory(all)).toBe(RESOURCE_BOOK_ALL_TARGET);
|
||||
// 「所有资源」页自己按栏目渲染全部资源卡;画本场景不再复制一份,否则同一张
|
||||
// 资源卡会挂载两次(DOM 查询与 FLIP 键都会撞车)。
|
||||
for (const category of [
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'audio',
|
||||
'document',
|
||||
'unclassified',
|
||||
'version',
|
||||
] as const) {
|
||||
expect(resourceBookCategoryCardPresentation(all, category)).toBeNull();
|
||||
}
|
||||
expect(
|
||||
resourceBookCategoryCardPresentation(all, RESOURCE_BOOK_ALL_TARGET),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('identifies the render-layer sentinel without widening the section contract', () => {
|
||||
expect(isResourceBookAllTarget(RESOURCE_BOOK_ALL_TARGET)).toBe(true);
|
||||
expect(isResourceBookAllTarget('unclassified')).toBe(false);
|
||||
expect(isResourceBookAllTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user