Files
suzmii 9788248d9b 「所有资源」页改走画本场景:与栏目页同一套卡片宿主与几何,并可拖动
- 新增 resourceBookShowsAllCategories:把"子画布里的 all"这一态判定收在 resourceBookModel 一处
- resourceBookCategoryCardPresentation:展开态下真实栏目返回 child(卡片仍由各栏目组承载,每张资源只有一个宿主)、all 自己返回 null(只出标题栏与全量计数)
- 新增纯函数 buildResourceBookAllLayout:按栏目顺序纵向排"分带",用各栏目可见资源的 extent 定带宽高;卡片世界坐标 = 栏目内局部坐标 + 带原点
- 新增 resourceBookAllTitlebarRects:分带 → 栏目标题栏矩形,让展开态复用总览态"按矩形钉标题栏"的同一条渲染路径(ResourceBookScene 不需要知道"展开态"这个概念)
- 新增 resourceBookAllBandLocalPoint:展开态世界坐标 → 栏目内局部坐标,这是落盘前唯一的换算(减带原点)
- buildResourceBookScenePlan:展开态下 all 只出标题栏(钉在视口 + 全量计数),真实栏目按带偏移铺满 child 卡;真实栏目这一支仍按 presentation === child 取卡,"能出卡的判定"只有一处
- buildResourceBookScenePlan:返回总览的淡出改为按"刚离开的那段子画布"判定(exitingCategory 传 all 时覆盖所有栏目),并用带偏移位置淡出,否则返回动画会跳位置
- buildResourceBookScenePlan:「所有资源」组不再接 child / exiting 卡,避免同一张卡在两组里各挂一份(DOM 查询与 FLIP 键撞车)
- index.tsx:删掉独立网格页组件、其 host 块与 resourceBookAllResourcesSections memo,展开态整体交给画本场景
- index.tsx:新增内存态 resourceBookAllViewport(不落盘、不进按真实栏目键的 resourceCanvasViewports),滚轮 / 缩放 / 适应 / 平移 / 框选都作用到它
- index.tsx:分带几何按"项目 + 排序 + 可见资源集"冻结(签名 + ref 短路;签名不含 state.token——返回总览时 token 也会变,含它会在淡出期间重算),拖动不重算带高,避免"拖上面一带让后面所有带整体位移"
- index.tsx:拖动放开——展开态下每张卡都能拖,section 取 resource.category(栏目页仍只拖当前分页栏目)
- index.tsx:提交前用 resourceBookAllBandLocalPoint 把世界坐标减回带原点,写回的仍是同一 mode 同一 sidecar 的同一 section(.agent/workbench/resource-layouts/{dependency,type}.json),没有第二份空间,不改契约与 schema
- index.tsx:renderResourceBookCard 去掉 options.draggable:展开态已可拖,这个"只读"开关没有调用方了
- 测试:resourceBookModel.test 钉住展开态 presentation 与"栏目页仍是原判据";resourceBookLayout.test 退役旧分组函数用例,新增分带几何 / 标题栏矩形 / 减带原点换算 / 展开态计划(含"每张资源只有一个宿主"与按带位置淡出)断言
- 测试:appSurface 两处「所有资源」判据从旧网格宿主改为画本场景(.game-resource-book-scene-card[data-resource-book-category] 与 .is-active[data-resource-book-category="all"]),并断言旧网格容器 / 宿主一个都不再存在
2026-09-11 23:33:08 +08:00

243 lines
7.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
initialResourceBookState,
isResourceBookAllTarget,
RESOURCE_BOOK_ALL_TARGET,
resourceBookCategoryCardPresentation,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsAllCategories,
resourceBookShowsExitingCards,
resourceBookShowsOverviewCards,
} from '../src/view/project-development/resourceBookModel';
describe('resource book state machine', () => {
it('keeps overview titles and preview cards visible on the steady main canvas', () => {
expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true);
});
it('commits the child target immediately and only keeps the motion phase transient', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
expect(entering).toMatchObject({
view: 'child',
category: 'document',
phase: 'entering',
token: 1,
});
expect(resourceBookSceneCategory(entering)).toBe('document');
});
it('retargets directly from one child canvas to another', () => {
const current = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switching = resourceBookReducer(current, {
type: 'open-category',
category: 'scene',
token: 2,
});
expect(switching).toMatchObject({
view: 'child',
category: 'scene',
phase: 'entering',
});
expect(resourceBookSceneCategory(switching)).toBe('scene');
});
it('marks a return to the main canvas separately from a child enter transition', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const returning = resourceBookReducer(entering, {
type: 'return-to-main',
token: 2,
});
expect(returning).toMatchObject({
view: 'main',
category: null,
phase: 'returning-main',
});
});
it('lets an explicit collapse cancel an in-flight child switch', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switching = resourceBookReducer(entering, {
type: 'open-category',
category: 'scene',
token: 2,
});
const main = resourceBookReducer(switching, {
type: 'return-to-main',
token: 3,
});
expect(main).toMatchObject({
view: 'main',
category: null,
phase: 'returning-main',
token: 3,
});
});
it('ignores stale transition callbacks', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switched = resourceBookReducer(entering, {
type: 'open-category',
category: 'scene',
token: 2,
});
expect(
resourceBookReducer(switched, { type: 'finish-transition', token: 1 }),
).toEqual(switched);
});
});
describe('resource book card presentation', () => {
it('uses the overview stack on the steady main canvas', () => {
expect(
resourceBookCategoryCardPresentation(initialResourceBookState, 'scene'),
).toBe('overview');
});
it('keeps other categories mounted while entering so they can fade out', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'scene',
token: 1,
});
expect(resourceBookCategoryCardPresentation(entering, 'scene')).toBe(
'child',
);
expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe(
'overview',
);
});
it('drops other categories once the child canvas is steady', () => {
const steady = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'scene',
token: 1,
}),
{ type: 'finish-transition', token: 1 },
);
expect(resourceBookCategoryCardPresentation(steady, 'scene')).toBe('child');
expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull();
});
it('returns every category to the stack and exposes the exiting window', () => {
const returning = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'scene',
token: 1,
}),
{ type: 'return-to-main', token: 2 },
);
expect(resourceBookCategoryCardPresentation(returning, 'scene')).toBe(
'overview',
);
expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe(
'overview',
);
expect(resourceBookShowsExitingCards(returning)).toBe(true);
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('spreads every real category on the canvas and keeps the all tile as a titlebar', () => {
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);
expect(resourceBookShowsAllCategories(all)).toBe(true);
expect(resourceBookShowsAllCategories(initialResourceBookState)).toBe(
false,
);
// 展开态把每个真实栏目铺在自己的分带上(卡片仍由画本场景渲染,每张卡只有一个宿主:
// 资源只属于一个栏目,所以 `card:*` 键不会撞车)。
for (const category of [
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
'version',
] as const) {
expect(resourceBookCategoryCardPresentation(all, category)).toBe('child');
}
// 「所有资源」自己只出钉住的标题栏与全量计数,不再复制一份卡片:否则同一张资源卡会
// 在里面那一组和各栏目组各挂一次(DOM 查询与 FLIP 键都会撞车)。
expect(
resourceBookCategoryCardPresentation(all, RESOURCE_BOOK_ALL_TARGET),
).toBeNull();
});
it('keeps a plain category page on the original single-column presentation', () => {
const child = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
}),
{ type: 'finish-transition', token: 1 },
);
expect(resourceBookShowsAllCategories(child)).toBe(false);
expect(resourceBookCategoryCardPresentation(child, 'document')).toBe(
'child',
);
// 静止态的非活动栏目一张都不挂。
expect(resourceBookCategoryCardPresentation(child, 'audio')).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);
});
});