资源总览新增「所有资源」汇总卡与全部资源页
- 渲染层新增 `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;
|
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 {
|
.game-resource-section-stack {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,10 @@ import type {
|
|||||||
ResourceBookCardPresentation,
|
ResourceBookCardPresentation,
|
||||||
ResourceBookCategory,
|
ResourceBookCategory,
|
||||||
ResourceBookState,
|
ResourceBookState,
|
||||||
|
ResourceBookTarget,
|
||||||
} from './resourceBookModel';
|
} from './resourceBookModel';
|
||||||
import {
|
import {
|
||||||
|
isResourceBookAllTarget,
|
||||||
resourceBookCategoryCardPresentation,
|
resourceBookCategoryCardPresentation,
|
||||||
resourceBookShowsExitingCards,
|
resourceBookShowsExitingCards,
|
||||||
} from './resourceBookModel';
|
} 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(
|
export function groupResourceBookResourcesByCategory(
|
||||||
resources: readonly ProjectResource[],
|
resources: readonly ProjectResource[],
|
||||||
) {
|
) {
|
||||||
@@ -174,13 +199,13 @@ export function resourceBookSceneCardKey(resourceId: string) {
|
|||||||
return `card:${resourceId}`;
|
return `card:${resourceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resourceBookSceneTitleKey(category: ResourceBookCategory) {
|
export function resourceBookSceneTitleKey(category: ResourceBookTarget) {
|
||||||
return `title:${category}`;
|
return `title:${category}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResourceBookSceneCardPlan = {
|
export type ResourceBookSceneCardPlan = {
|
||||||
key: string;
|
key: string;
|
||||||
category: ResourceBookCategory;
|
category: ResourceBookTarget;
|
||||||
resource: ProjectResource;
|
resource: ProjectResource;
|
||||||
presentation: ResourceBookCardPresentation;
|
presentation: ResourceBookCardPresentation;
|
||||||
stackIndex: number;
|
stackIndex: number;
|
||||||
@@ -191,7 +216,7 @@ export type ResourceBookSceneCardPlan = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceBookSceneCategoryPlan = {
|
export type ResourceBookSceneCategoryPlan = {
|
||||||
category: ResourceBookCategory;
|
category: ResourceBookTarget;
|
||||||
resourceCount: number;
|
resourceCount: number;
|
||||||
titlebarActive: boolean;
|
titlebarActive: boolean;
|
||||||
titlebarOpacity: number;
|
titlebarOpacity: number;
|
||||||
@@ -205,6 +230,7 @@ export type ResourceBookSceneCategoryPlan = {
|
|||||||
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
|
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
|
||||||
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
|
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
|
||||||
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
|
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
|
||||||
|
* - 「所有资源」组只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载。
|
||||||
*/
|
*/
|
||||||
export function buildResourceBookScenePlan({
|
export function buildResourceBookScenePlan({
|
||||||
state,
|
state,
|
||||||
@@ -220,18 +246,18 @@ export function buildResourceBookScenePlan({
|
|||||||
exitingCards = [],
|
exitingCards = [],
|
||||||
}: {
|
}: {
|
||||||
state: ResourceBookState;
|
state: ResourceBookState;
|
||||||
visibleCategoryOrder: readonly ResourceBookCategory[];
|
visibleCategoryOrder: readonly ResourceBookTarget[];
|
||||||
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
|
resourcesByCategory: ReadonlyMap<ResourceBookTarget, ProjectResource[]>;
|
||||||
overviewRects: ReadonlyMap<ResourceBookCategory, ResourceBookOverviewRect>;
|
overviewRects: ReadonlyMap<ResourceBookTarget, ResourceBookOverviewRect>;
|
||||||
positions: ReadonlyMap<string, { x: number; y: number }>;
|
positions: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
||||||
visibleResourceIds: ReadonlySet<string>;
|
visibleResourceIds: ReadonlySet<string>;
|
||||||
categoryViewCenters: ReadonlyMap<
|
categoryViewCenters: ReadonlyMap<
|
||||||
ResourceBookCategory,
|
ResourceBookTarget,
|
||||||
{ x: number; y: number }
|
{ x: number; y: number }
|
||||||
>;
|
>;
|
||||||
cardsReady: boolean;
|
cardsReady: boolean;
|
||||||
exitingCategory?: ResourceBookCategory | null;
|
exitingCategory?: ResourceBookTarget | null;
|
||||||
exitingCards?: readonly ResourceBookSceneCardPlan[];
|
exitingCards?: readonly ResourceBookSceneCardPlan[];
|
||||||
}): ResourceBookSceneCategoryPlan[] {
|
}): ResourceBookSceneCategoryPlan[] {
|
||||||
return visibleCategoryOrder.map((category) => {
|
return visibleCategoryOrder.map((category) => {
|
||||||
@@ -241,7 +267,11 @@ export function buildResourceBookScenePlan({
|
|||||||
const categoryResources = resourcesByCategory.get(category) ?? [];
|
const categoryResources = resourcesByCategory.get(category) ?? [];
|
||||||
const stacks = groupResourceBookStacks(categoryResources);
|
const stacks = groupResourceBookStacks(categoryResources);
|
||||||
const childLayout = presentation === 'child';
|
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) => {
|
stacks.forEach(([, items], stackColumn) => {
|
||||||
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
|
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
|
||||||
const stackItems = childLayout
|
const stackItems = childLayout
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
import type { ProjectResourceCategory } from './resourceProjectionModel';
|
import type { ProjectResourceCategory } from './resourceProjectionModel';
|
||||||
|
|
||||||
export type ResourceBookCategory = ProjectResourceCategory;
|
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 ResourceBookPhase = 'idle' | 'entering' | 'returning-main';
|
||||||
|
|
||||||
export type ResourceBookState = {
|
export type ResourceBookState = {
|
||||||
view: 'main' | 'child';
|
view: 'main' | 'child';
|
||||||
category: ResourceBookCategory | null;
|
category: ResourceBookTarget | null;
|
||||||
phase: ResourceBookPhase;
|
phase: ResourceBookPhase;
|
||||||
token: number;
|
token: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceBookAction =
|
export type ResourceBookAction =
|
||||||
| { type: 'open-category'; category: ResourceBookCategory; token: number }
|
| { type: 'open-category'; category: ResourceBookTarget; token: number }
|
||||||
| { type: 'finish-transition'; token: number }
|
| { type: 'finish-transition'; token: number }
|
||||||
| { type: 'return-to-main'; token: number }
|
| { type: 'return-to-main'; token: number }
|
||||||
| { type: 'reset'; token: number };
|
| { type: 'reset'; token: number };
|
||||||
@@ -91,11 +112,16 @@ export type ResourceBookCardPresentation = 'child' | 'overview' | 'exiting';
|
|||||||
|
|
||||||
export function resourceBookCategoryCardPresentation(
|
export function resourceBookCategoryCardPresentation(
|
||||||
state: ResourceBookState,
|
state: ResourceBookState,
|
||||||
category: ResourceBookCategory,
|
category: ResourceBookTarget,
|
||||||
): Exclude<ResourceBookCardPresentation, 'exiting'> | null {
|
): Exclude<ResourceBookCardPresentation, 'exiting'> | null {
|
||||||
if (state.view === 'main') {
|
if (state.view === 'main') {
|
||||||
return 'overview';
|
return 'overview';
|
||||||
}
|
}
|
||||||
|
// 「所有资源」页自己按栏目渲染全部资源卡,画本场景不再复制一份;
|
||||||
|
// 否则同一张资源卡会在总览/全部资源页同时挂载(DOM 查询与 FLIP 键都会撞车)。
|
||||||
|
if (isResourceBookAllTarget(state.category)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (category === state.category) {
|
if (category === state.category) {
|
||||||
return 'child';
|
return 'child';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1130,7 +1130,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
|||||||
),
|
),
|
||||||
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT),
|
).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(
|
const overviewCardLayers = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'.game-resource-book-scene-card[data-resource-book-category="character"]',
|
'.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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildResourceBookAllResourcesSections,
|
||||||
buildResourceBookScenePlan,
|
buildResourceBookScenePlan,
|
||||||
groupResourceBookStacks,
|
groupResourceBookStacks,
|
||||||
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
||||||
resourceBookOverviewCardLayout,
|
resourceBookOverviewCardLayout,
|
||||||
|
type ResourceBookOverviewRect,
|
||||||
resourceBookSceneCardKey,
|
resourceBookSceneCardKey,
|
||||||
selectResourceBookOverviewCards,
|
selectResourceBookOverviewCards,
|
||||||
} from '../src/view/project-development/resourceBookLayout';
|
} 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';
|
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
||||||
|
|
||||||
function resource(id: string): ProjectResource {
|
function resource(
|
||||||
|
id: string,
|
||||||
|
category: ProjectResource['category'] = 'unclassified',
|
||||||
|
): ProjectResource {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
category: 'unclassified',
|
category,
|
||||||
subtype: 'image',
|
subtype: 'image',
|
||||||
label: id,
|
label: id,
|
||||||
path: `assets/${id}.png`,
|
path: `assets/${id}.png`,
|
||||||
@@ -278,4 +287,90 @@ describe('buildResourceBookScenePlan', () => {
|
|||||||
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
|
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
|
||||||
).toHaveLength(1);
|
).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 {
|
import {
|
||||||
initialResourceBookState,
|
initialResourceBookState,
|
||||||
|
isResourceBookAllTarget,
|
||||||
|
RESOURCE_BOOK_ALL_TARGET,
|
||||||
resourceBookCategoryCardPresentation,
|
resourceBookCategoryCardPresentation,
|
||||||
resourceBookReducer,
|
resourceBookReducer,
|
||||||
resourceBookSceneCategory,
|
resourceBookSceneCategory,
|
||||||
@@ -162,3 +164,54 @@ describe('resource book card presentation', () => {
|
|||||||
expect(resourceBookShowsExitingCards(initialResourceBookState)).toBe(false);
|
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