From 2f8753f597b46fbdeb30edc593d5c3d4e63b884c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 20:41:19 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=B8=BA=206=20=E7=B1=BB=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=8A=A0=E9=A1=B9=E7=9B=AE=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=A0=8F=E7=9B=AE=EF=BC=88=E9=98=B6=E6=AE=B5=E4=BA=8C=EF=BC=9A?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E8=BD=B4=E4=B8=8E=E6=A0=8F=E7=9B=AE=E8=A1=A8?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合 - 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史 - RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER - reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留 - 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本 - 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类 - 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目 - 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区 - index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏 - index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2) - index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片 - 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染 - 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例 - 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互) - 同步 PRD 与共享记忆决策记录的栏目口径描述 --- .../resourceCanvasHistoryModel.ts | 51 ++- .../resourceCanvasToolbarModel.ts | 12 +- .../ResourceDependencyOverlay.tsx | 15 +- .../src/view/project-development/index.tsx | 88 ++-- .../resourceCanvasLayoutModel.ts | 42 +- .../resourceCardPreviewModel.ts | 21 +- .../project-development/resourceEditModel.ts | 10 +- .../resourceProjectionModel.ts | 73 ++-- .../resourceSectionHeightModel.ts | 4 +- .../useProjectResourceCanvasLayout.ts | 6 +- .../useProjectResourceCardPreviews.ts | 32 +- .../useProjectResourceSectionHeights.ts | 8 +- .../tests/ResourceDependencyOverlay.test.ts | 58 +-- .../tests/appSurface/harness.ts | 5 +- .../tests/appSurface/home.suite.ts | 16 +- .../appSurface/project-development.suite.ts | 378 ++++++++++-------- .../projectResourceLiveIntegration.test.tsx | 4 +- .../projectResourceProjectionModel.test.ts | 47 +-- .../tests/resourceBookLayout.test.ts | 53 +-- .../tests/resourceBookModel.test.ts | 26 +- .../resourceCanvasCodeOnlyProject.test.tsx | 125 ++++++ .../tests/resourceCanvasController.test.ts | 2 +- .../tests/resourceCanvasLayoutModel.test.ts | 209 +++++++--- .../tests/resourceCanvasToolbarModel.test.ts | 2 +- .../tests/resourceEditModel.test.ts | 14 +- .../tests/resourceRename.test.tsx | 10 +- .../tests/resourceVersionSwitch.test.tsx | 7 +- .../useProjectResourceCardPreviews.test.ts | 7 +- .../shared-memory/decision-log.md | 2 +- .../shared/src/contracts/gameCreationApp.ts | 17 +- 30 files changed, 848 insertions(+), 496 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasCodeOnlyProject.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts index bd5d4202a..260ed1aa6 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts @@ -6,13 +6,26 @@ * 本模块只做纯函数的快照栈。 */ -import type { ProjectResourceCanvasSection } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + isProjectResourceCanvasCategory, + type PersistedProjectResourceCanvasSection, + type ProjectResourceCanvasCategory, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; export const MAX_RESOURCE_CANVAS_HISTORY_STEPS = 40; export type ResourceCanvasLayoutSnapshotEntry = { resourceId: string; - section: ProjectResourceCanvasSection; + section: ProjectResourceCanvasCategory; + x: number; + y: number; + manuallyPlaced: boolean; +}; + +/** 快照输入:直接消费布局 sidecar 的坐标,`section` 允许仍是旧栏目取值。 */ +export type ResourceCanvasLayoutSnapshotEntryInput = { + resourceId: string; + section: PersistedProjectResourceCanvasSection; x: number; y: number; manuallyPlaced: boolean; @@ -41,21 +54,29 @@ export function createResourceCanvasHistory(): ResourceCanvasHistory { /** * 按 resourceId 排序后落快照,保证同一组坐标无论 map 顺序如何都比较得出相等。 + * + * 画布上的坐标经协调后一定是现行分区;仍是旧栏目取值的坐标不属于本会话的撤销范围。 */ export function captureResourceCanvasSnapshot( - positions: readonly ResourceCanvasLayoutSnapshotEntry[], + positions: readonly ResourceCanvasLayoutSnapshotEntryInput[], ): ResourceCanvasLayoutSnapshot { - return { - entries: positions - .map((position) => ({ - resourceId: position.resourceId, - section: position.section, - x: position.x, - y: position.y, - manuallyPlaced: position.manuallyPlaced, - })) - .sort((left, right) => left.resourceId.localeCompare(right.resourceId)), - }; + const entries: ResourceCanvasLayoutSnapshotEntry[] = []; + for (const position of positions) { + if (!isProjectResourceCanvasCategory(position.section)) { + continue; + } + entries.push({ + resourceId: position.resourceId, + section: position.section, + x: position.x, + y: position.y, + manuallyPlaced: position.manuallyPlaced, + }); + } + entries.sort((left, right) => + left.resourceId.localeCompare(right.resourceId), + ); + return { entries }; } export function resourceCanvasSnapshotsEqual( @@ -189,7 +210,7 @@ export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) { */ export function resolveResourceCanvasRestoreEntries( snapshot: ResourceCanvasLayoutSnapshot, - current: readonly ResourceCanvasLayoutSnapshotEntry[], + current: readonly ResourceCanvasLayoutSnapshotEntryInput[], ): ResourceCanvasLayoutSnapshotEntry[] { const currentById = new Map( current.map((entry) => [entry.resourceId, entry] as const), diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts index ec8f42913..c8d63a803 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -6,7 +6,10 @@ import type { } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel'; -import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { + type ProjectResource, + projectResourceDisplayKind, +} from '../../view/project-development/resourceProjectionModel'; import { isResourceCanvasExportable } from './resourceCanvasAssetTransferModel'; /** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */ @@ -33,7 +36,7 @@ const CANVAS_ASSET_KINDS = new Set([ ]); export function resourceCanvasMediaType( - resource: Pick, + resource: Pick, ): CanvasMediaType | undefined { const mediaType = canonicalProjectedResourceMediaType({ mediaType: resource.mediaType, @@ -45,7 +48,10 @@ export function resourceCanvasMediaType( if (mediaType.startsWith('video/')) { return 'video'; } - if (mediaType.startsWith('audio/') || resource.category === 'audio') { + if ( + mediaType.startsWith('audio/') || + projectResourceDisplayKind(resource) === 'audio' + ) { return 'audio'; } return undefined; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index 8884691b5..322376bd6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -9,8 +9,9 @@ import { } from 'react'; import type { + PersistedProjectResourceCanvasSection, + ProjectResourceCanvasCategory, ProjectResourceCanvasPosition, - ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resourceCanvasCardSize, @@ -38,7 +39,7 @@ type RectLookup = { export type ResourceDependencyOverlayProps = { graph: ProjectResourceGraph; positions: readonly ProjectResourceCanvasPosition[]; - section?: ProjectResourceCanvasSection; + section?: ProjectResourceCanvasCategory; visibleResourceIds: ReadonlySet; cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId; canvasViewport?: Readonly; @@ -180,7 +181,10 @@ function connectionAxis(source: Rect, target: Rect): ConnectionAxis { function referenceStaysInSection( edge: ProjectResourceReferenceEdge, - sectionByResourceId: ReadonlyMap, + sectionByResourceId: ReadonlyMap< + string, + PersistedProjectResourceCanvasSection + >, ) { const sourceSection = sectionByResourceId.get(edge.sourceResourceId); return ( @@ -192,7 +196,10 @@ function referenceStaysInSection( function referenceRouteOffsets( edges: readonly ProjectResourceReferenceEdge[], rectByResourceId: RectLookup, - sectionByResourceId: ReadonlyMap, + sectionByResourceId: ReadonlyMap< + string, + PersistedProjectResourceCanvasSection + >, ) { type Endpoint = { axis: ConnectionAxis; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 6b16331f2..8c8522fb9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -16,17 +16,18 @@ import { import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, - Code2, Crosshair, FileText, FolderTree, Gamepad2, Image, Info, + LayoutGrid, ListFilter, Maximize2, Minus, Music2, + PackageOpen, Pause, Pencil, Play, @@ -38,6 +39,7 @@ import { SlidersHorizontal, Sparkles, Undo2, + Users, X, ZoomOut, } from 'lucide-react'; @@ -87,6 +89,7 @@ import { import { dispatchResourceReferenceInsert, RESOURCE_REFERENCE_FILTERS, + resourceReferenceCategoryLabel, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; @@ -188,7 +191,6 @@ import { RESOURCE_CANVAS_SECTION_MIN_HEIGHT, RESOURCE_CANVAS_SECTION_MIN_WIDTH, RESOURCE_CANVAS_SECTION_ORDER, - RESOURCE_CANVAS_VISIBLE_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, resourceCanvasContentBounds, @@ -376,10 +378,12 @@ function defaultResourceCanvasCategoryFilters(): ResourceCanvasCategoryFilterByS function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { const byCategory = (): ResourceCanvasViewportByCategory => ({ - document: { x: 48, y: 48, scale: 1 }, - art: { x: 48, y: 48, scale: 1 }, + 'ui-interaction': { x: 48, y: 48, scale: 1 }, + character: { x: 48, y: 48, scale: 1 }, + scene: { x: 48, y: 48, scale: 1 }, audio: { x: 48, y: 48, scale: 1 }, - code: { x: 48, y: 48, scale: 1 }, + document: { x: 48, y: 48, scale: 1 }, + unclassified: { x: 48, y: 48, scale: 1 }, version: { x: 48, y: 48, scale: 1 }, }); return { @@ -464,22 +468,29 @@ export type ProjectDevelopmentViewProps = { }; const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER; -const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER; +/** + * 栏目标签复用资源筛选的唯一中文口径;「项目版本」不是资源资产、单独成栏, + * 不在筛选口径内,因此只有它在这里给出文案。 + */ const categoryLabels: Record = { - code: '游戏代码', - document: '设计文档', + 'ui-interaction': resourceReferenceCategoryLabel('ui-interaction'), + character: resourceReferenceCategoryLabel('character'), + scene: resourceReferenceCategoryLabel('scene'), + audio: resourceReferenceCategoryLabel('audio'), + document: resourceReferenceCategoryLabel('document'), + unclassified: resourceReferenceCategoryLabel('unclassified'), version: '项目版本', - art: '美术资源', - audio: '音乐音效', }; const categoryIcons: Record = { - code: Code2, - document: FileText, - version: Gamepad2, - art: Image, + 'ui-interaction': LayoutGrid, + character: Users, + scene: Image, audio: Music2, + document: FileText, + unclassified: PackageOpen, + version: Gamepad2, }; const approvalOptions: Array<{ @@ -1767,10 +1778,11 @@ export default function ProjectDevelopmentView({ }), [manifestTaskById, projectedResources, resourceGraph], ); - const canvasResources = useMemo( - () => resources.filter((resource) => resource.category !== 'code'), - [resources], - ); + /** + * 画布消费全部已投影资源:分区轴改为资产功能分类后不再有被栏目排除的内部资源, + * 只登记游戏代码的项目也会在「待归类」栏目正常显示卡片。 + */ + const canvasResources = resources; /** 资源画布选中态:单选是长度为 1 的数组,多选/框选保持同一份状态。 */ const selectedResourceId = selectedResourceIds[0] ?? null; const selectedResource = @@ -1969,7 +1981,7 @@ export default function ProjectDevelopmentView({ const visibleResourcesByCategory = useMemo( () => new Map( - visibleCategoryOrder.map((category) => [ + categoryOrder.map((category) => [ category, visibleResources.filter((resource) => resource.category === category), ]), @@ -1979,7 +1991,7 @@ export default function ProjectDevelopmentView({ const projectResourcesByCategory = useMemo( () => new Map( - visibleCategoryOrder.map((category) => [ + categoryOrder.map((category) => [ category, canvasResources.filter((resource) => resource.category === category), ]), @@ -1989,7 +2001,7 @@ export default function ProjectDevelopmentView({ const resourceIdsByCategory = useMemo( () => new Map( - visibleCategoryOrder.map((category) => [ + categoryOrder.map((category) => [ category, new Set( (projectResourcesByCategory.get(category) ?? []).map( @@ -2019,7 +2031,7 @@ export default function ProjectDevelopmentView({ [canvasResources], ); const resourcePageCategories = useMemo( - () => (resourcePageCategoryKey ? [...visibleCategoryOrder] : []), + () => (resourcePageCategoryKey ? [...categoryOrder] : []), [resourcePageCategoryKey], ); const activePageCategory = @@ -2133,18 +2145,16 @@ export default function ProjectDevelopmentView({ return; } - const categoriesWithNewResources = visibleCategoryOrder.filter( - (category) => { - if (category === viewedResourceCategory) { - return false; - } - const previousIds = - previousSnapshot.resourceIdsByCategory.get(category) ?? new Set(); - return Array.from(resourceIdsByCategory.get(category) ?? []).some( - (resourceId) => !previousIds.has(resourceId), - ); - }, - ); + const categoriesWithNewResources = categoryOrder.filter((category) => { + if (category === viewedResourceCategory) { + return false; + } + const previousIds = + previousSnapshot.resourceIdsByCategory.get(category) ?? new Set(); + return Array.from(resourceIdsByCategory.get(category) ?? []).some( + (resourceId) => !previousIds.has(resourceId), + ); + }); resourceCategorySnapshotRef.current = { scopeKey: resourceCategoryScopeKey, resourceIdsByCategory, @@ -2299,7 +2309,7 @@ export default function ProjectDevelopmentView({ height: RESOURCE_CANVAS_FALLBACK_HEIGHT, }; return new Map( - visibleCategoryOrder.map((category) => { + categoryOrder.map((category) => { const viewport = normalizeResourceBookViewport( resourceCanvasViewports[sortMode][category], ); @@ -2639,7 +2649,7 @@ export default function ProjectDevelopmentView({ () => buildResourceBookScenePlan({ state: resourceBookState, - visibleCategoryOrder, + visibleCategoryOrder: categoryOrder, resourcesByCategory: projectResourcesByCategory, overviewRects: resourceBookOverviewRects, positions: resourcePositionById, @@ -2670,7 +2680,7 @@ export default function ProjectDevelopmentView({ ); const resourceBookOverviewReady = useMemo( () => - visibleCategoryOrder.every((category) => { + categoryOrder.every((category) => { const rect = resourceBookOverviewRects.get(category); return Boolean(rect && rect.width > 0 && rect.height > 0); }), @@ -5834,7 +5844,7 @@ export default function ProjectDevelopmentView({
- {visibleCategoryOrder.map((category) => ( + {categoryOrder.map((category) => ( ) : (
- {visibleCategoryOrder.map((category) => { + {categoryOrder.map((category) => { const Icon = categoryIcons[category]; const sectionHeight = resourceSectionHeights.sectionStates.get(category); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index d4133ab05..715bd36ff 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -8,11 +8,13 @@ import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + PROJECT_RESOURCE_CANVAS_SECTIONS, + type ProjectResourceCanvasCategory, type ProjectResourceCanvasLayout, type ProjectResourceCanvasLayoutMode, type ProjectResourceCanvasPosition, - type ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping'; export const RESOURCE_CANVAS_CARD_WIDTH = 180; export const RESOURCE_CANVAS_CARD_HEIGHT = 128; @@ -41,16 +43,12 @@ export const RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE = 1.5; */ export const RESOURCE_CANVAS_FIT_PADDING = 16; -export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] = - ['document', 'art', 'audio', 'code', 'version']; - /** - * Ordinary resource-canvas navigation intentionally omits game code. The - * complete five-section order above remains the internal layout/sidecar - * contract so existing code-resource coordinates are not rewritten. + * 栏目顺序 = 6 类资源资产分类 + 末尾独立的「项目版本」栏目。项目版本不是资源资产, + * 6 类资产分类轴对它不适用,因此固定单独成栏。 */ -export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] = - ['document', 'art', 'audio', 'version']; +export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasCategory[] = + PROJECT_RESOURCE_CANVAS_SECTIONS; export type ResourceCanvasCardSize = { width: number; @@ -106,7 +104,7 @@ export type ResourceCanvasCardSizeByResourceId = ReadonlyMap< export type ResourceCanvasItem = { id: string; - category: ProjectResourceCanvasSection; + category: ProjectResourceCanvasCategory; subtype: string; label: string; mediaType: string; @@ -880,7 +878,7 @@ function dependencyColumnXByDepth( } function dependencyAutomaticPositions( - section: ProjectResourceCanvasSection, + section: ProjectResourceCanvasCategory, sectionResources: readonly ResourceCanvasItem[], automaticResources: readonly ResourceCanvasItem[], preservedPositions: readonly ProjectResourceCanvasPosition[], @@ -1142,8 +1140,8 @@ export function reconcileResourceCanvasLayout( resources, explicitCardSizeByResourceId, ); - const resourceById = new Map( - resources.map((resource) => [resource.id, resource]), + const categoryByResourceId = new Map( + resources.map((resource) => [resource.id, resource.category]), ); const positionsBySection = new Map( RESOURCE_CANVAS_SECTION_ORDER.map((section) => [ @@ -1151,13 +1149,19 @@ export function reconcileResourceCanvasLayout( [] as ProjectResourceCanvasPosition[], ]), ); + // 读时兼容映射:旧栏目值按资源当前分区归并,只改写 section,坐标与手动标记原样保留。 const preserved = source.positions.filter((position) => { - const resource = resourceById.get(position.resourceId); - const keep = resource?.category === position.section; - if (keep) { - positionsBySection.get(position.section)?.push(position); + const normalized = normalizeResourceCanvasPosition( + position, + categoryByResourceId, + ); + if (!normalized) { + return false; } - return keep; + positionsBySection + .get(normalized.position.section) + ?.push(normalized.position); + return true; }); const preservedIds = new Set( preserved.map((position) => position.resourceId), @@ -1239,7 +1243,7 @@ export function reconcileResourceCanvasLayout( export function moveResourceCanvasPosition( layout: ProjectResourceCanvasLayout, resourceId: string, - section: ProjectResourceCanvasSection, + section: ProjectResourceCanvasCategory, x: number, y: number, ): ProjectResourceCanvasLayout { diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index 36eff7bff..6d300849a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -1,4 +1,7 @@ -import type { ProjectResource } from './resourceProjectionModel'; +import { + type ProjectResource, + projectResourceDisplayKind, +} from './resourceProjectionModel'; export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3; export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48; @@ -137,16 +140,20 @@ const videoExtension = /\.(mp4|webm|mov)$/iu; export function projectResourceCardPreviewKind( resource: ProjectResource, ): ProjectResourceCardPreviewKind { - if (resource.category === 'version') { + if (resource.subtype === 'project-version') { return 'version'; } - if (resource.category === 'code') { - return 'code'; - } - if (resource.category === 'document') { + if (resource.subtype === 'agent-result') { return 'document'; } - if (resource.category === 'audio') { + const kind = projectResourceDisplayKind(resource); + if (kind === 'code') { + return 'code'; + } + if (kind === 'document') { + return 'document'; + } + if (kind === 'audio') { return 'audio'; } const mediaType = resource.mediaType.trim().toLowerCase(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index f33f292eb..0fb50dbab 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -1,4 +1,7 @@ -import type { ProjectResource } from './resourceProjectionModel'; +import { + type ProjectResource, + projectResourceDisplayKind, +} from './resourceProjectionModel'; export type LocalProjectResourceEditKind = | 'image-reference' @@ -151,7 +154,10 @@ export function resolveProjectResourceEditCapability( semanticNotice: null, }; } - if (resource.category === 'audio' || mediaType.startsWith('audio/')) { + if ( + mediaType.startsWith('audio/') || + projectResourceDisplayKind(resource) === 'audio' + ) { return { route: 'derive', editKind: isBackgroundMusic(resource) diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index d3737c933..9e656b3ed 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -5,10 +5,9 @@ import { type GameCreationAppManifest, type GameIterationVersion, type ProjectResourceCanvasCategory, - type ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -export type ProjectResourceCategory = ProjectResourceCanvasSection; +export type ProjectResourceCategory = ProjectResourceCanvasCategory; export type ProjectAttachmentResult = { fileName: string; @@ -146,47 +145,57 @@ export function projectedResourceKind(input: { return null; } +/** + * 资源显示类型判定:与准入使用同一套扩展名 / mediaType / manifest `kind` 口径。 + * + * manifest 资产的 `subtype` 就是 `asset.kind`,任务产物、附件与回执使用各自的稳定 + * 人造 subtype,不会命中 kind 规则,因此这里可以直接透传。 + */ +export function projectResourceDisplayKind( + resource: Pick, +): ProjectedResourceKind | null { + return projectedResourceKind({ + path: resource.path, + mediaType: resource.mediaType, + kind: resource.subtype, + }); +} + /** * Converts an already projected resource into short user-facing type text. * This is deliberately display-only: it derives from existing projection * fields and does not add a backend/read-model attribute. */ export function projectResourceTypeLabel( - resource: Pick< - ProjectResource, - 'category' | 'subtype' | 'path' | 'mediaType' - >, + resource: Pick, ): ProjectResourceTypeLabel { const subtype = resource.subtype.trim().toLowerCase(); const path = resource.path.trim().toLowerCase(); const mediaType = resource.mediaType.trim().toLowerCase(); + const kind = projectResourceDisplayKind(resource); - if (resource.category === 'version' || subtype === 'project-version') { + if (subtype === 'project-version') { return '项目版本'; } if (subtype === 'agent-result') { return 'Agent 回执'; } - if (resource.category === 'code') { + if (kind === 'code') { return '游戏代码'; } if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) { return 'SVG'; } - if ( - resource.category === 'audio' || - mediaType.startsWith('audio/') || - audioExtension.test(path) - ) { + if (kind === 'audio') { return '音频'; } if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) { return '视频'; } - if (resource.category === 'document' || mediaType.startsWith('text/')) { + if (kind === 'document') { return '文档'; } - if (resource.category === 'art' || mediaType.startsWith('image/')) { + if (kind === 'art') { return '图片'; } if (subtype === 'task-artifact') { @@ -250,22 +259,22 @@ export function projectResourcesFromReadModels( continue; } for (const path of task.artifacts) { - const category = projectedResourceKind({ path, mediaType: '' }); - if (!category) { + const kind = projectedResourceKind({ path, mediaType: '' }); + if (!kind) { continue; } resources.push({ id: `task:${task.id}:${path}`, - category, + category: projectResourceCanvasCategory({ subtype: 'task-artifact' }), subtype: 'task-artifact', label: fileName(path), path, mediaType: - category === 'document' + kind === 'document' ? '项目文档' - : category === 'code' + : kind === 'code' ? '游戏代码' - : category === 'art' + : kind === 'art' ? '美术产物' : '音乐音效产物', sourceLabel: '任务产物', @@ -281,20 +290,24 @@ export function projectResourcesFromReadModels( } for (const asset of manifest.assets) { - const category = projectedResourceKind({ + const kind = projectedResourceKind({ path: asset.localPath, mediaType: asset.mediaType, kind: asset.kind, }); - if (!category) { + if (!kind) { continue; } + const assetCategory = gameCreationAppAssetCategory(asset); const isPendingUiPrototype = asset.kind === 'ui-prototype' && taskById.get('design-foundation')?.status !== 'completed'; resources.push({ id: `asset:${asset.id}`, - category, + category: projectResourceCanvasCategory({ + subtype: asset.kind, + assetCategory, + }), subtype: asset.kind, label: `${fileName(asset.localPath)}${ isPendingUiPrototype ? '(待视觉验收)' : '' @@ -316,7 +329,7 @@ export function projectResourcesFromReadModels( referenceResourceIds: asset.source.referenceResourceIds ?? [], dependencies: [], dependencyDepth: 0, - assetCategory: gameCreationAppAssetCategory(asset), + assetCategory, assetTags: gameCreationAppAssetTags(asset), imageSequenceFrames: asset.imageSequenceFrames ?? null, imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null, @@ -327,16 +340,16 @@ export function projectResourcesFromReadModels( if (attachment.status !== 'imported' || !attachment.localPath) { continue; } - const category = projectedResourceKind({ + const kind = projectedResourceKind({ path: attachment.localPath, mediaType: attachment.mediaType, }); - if (!category) { + if (!kind) { continue; } resources.push({ id: `attachment:${attachment.localPath}`, - category, + category: projectResourceCanvasCategory({ subtype: 'attachment' }), subtype: 'attachment', label: attachment.fileName, path: attachment.localPath, @@ -355,7 +368,7 @@ export function projectResourcesFromReadModels( for (const result of agentResults) { resources.push({ id: `agent-result:${result.agentId}:${result.runId}`, - category: 'document', + category: projectResourceCanvasCategory({ subtype: 'agent-result' }), subtype: 'agent-result', label: result.title, path: `专业 Agent 文本回执 · ${result.label}`, @@ -390,7 +403,7 @@ export function projectResourcesFromReadModels( }; resources.push({ id: `version:${version.versionId}`, - category: 'version', + category: projectResourceCanvasCategory({ subtype: 'project-version' }), subtype: 'project-version', label: version.label, path: `项目版本 · ${version.versionId}`, diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceSectionHeightModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceSectionHeightModel.ts index b9c93d964..7800ec652 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceSectionHeightModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceSectionHeightModel.ts @@ -1,6 +1,6 @@ import type { + ProjectResourceCanvasCategory, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_CARD_HEIGHT } from './resourceCanvasLayoutModel'; @@ -32,7 +32,7 @@ export type ProjectResourceSectionZoomAction = export function projectResourceSectionHeightKey(input: { projectId: string; mode: ProjectResourceCanvasLayoutMode; - section: ProjectResourceCanvasSection; + section: ProjectResourceCanvasCategory; }) { return `${input.projectId}\n${input.mode}\n${input.section}`; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 5ace10696..35c5f56e5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { + ProjectResourceCanvasCategory, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, UpdateProjectResourceCanvasLayoutResult, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { @@ -38,7 +38,7 @@ type ManualLayoutWriteIntent = { kind: 'manual'; scopeEpoch: number; resourceId: string; - section: ProjectResourceCanvasSection; + section: ProjectResourceCanvasCategory; x: number; y: number; }; @@ -743,7 +743,7 @@ export function useProjectResourceCanvasLayout({ const commitPosition = useCallback( ( resourceId: string, - section: ProjectResourceCanvasSection, + section: ProjectResourceCanvasCategory, x: number, y: number, ) => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 58d0934a6..d5478a532 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -29,7 +29,10 @@ import { type ProjectResourceCardPreviewState, type ProjectResourceCardPreviewTransportPayload, } from './resourceCardPreviewModel'; -import type { ProjectResource } from './resourceProjectionModel'; +import { + type ProjectResource, + projectResourceDisplayKind, +} from './resourceProjectionModel'; type PreviewRequestReason = 'visible' | 'detail' | 'play'; @@ -58,6 +61,17 @@ type ObservedPreviewCard = { resource: ProjectResource; }; +function resourceReadKindLabel(resource: ProjectResource) { + const kind = projectResourceDisplayKind(resource); + if (kind === 'document') { + return '文档'; + } + if (kind === 'code') { + return '游戏代码'; + } + return '资源'; +} + function previewReadErrorMessage( resource: ProjectResource, error: unknown, @@ -65,25 +79,13 @@ function previewReadErrorMessage( const message = error instanceof Error ? error.message : String(error); if (message.includes('项目权限策略要求用户确认')) { return { - error: `当前项目策略要求先确认读取${ - resource.category === 'document' - ? '文档' - : resource.category === 'code' - ? '游戏代码' - : '资源' - },确认后请关闭详情并重试`, + error: `当前项目策略要求先确认读取${resourceReadKindLabel(resource)},确认后请关闭详情并重试`, retryable: true, }; } if (message.includes('项目权限策略拒绝执行')) { return { - error: `当前项目策略不允许读取${ - resource.category === 'document' - ? '文档' - : resource.category === 'code' - ? '游戏代码' - : '资源' - },调整策略后请关闭详情并重试`, + error: `当前项目策略不允许读取${resourceReadKindLabel(resource)},调整策略后请关闭详情并重试`, retryable: true, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts index 967be37af..1f8bc473e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts @@ -7,8 +7,8 @@ import { } from 'react'; import type { + ProjectResourceCanvasCategory, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel'; import { @@ -160,7 +160,7 @@ export function useProjectResourceSectionHeights(input: { const updateSectionHeight = useCallback( ( - section: ProjectResourceCanvasSection, + section: ProjectResourceCanvasCategory, action: ProjectResourceSectionHeightAction, ) => { const key = projectResourceSectionHeightKey({ projectId, mode, section }); @@ -186,7 +186,7 @@ export function useProjectResourceSectionHeights(input: { ); const setSectionZoom = useCallback( - (section: ProjectResourceCanvasSection, zoom: number) => { + (section: ProjectResourceCanvasCategory, zoom: number) => { const key = projectResourceSectionHeightKey({ projectId, mode, section }); const nextZoom = clampProjectResourceSectionZoom(zoom); setSessionZooms((current) => { @@ -206,7 +206,7 @@ export function useProjectResourceSectionHeights(input: { const updateSectionZoom = useCallback( ( - section: ProjectResourceCanvasSection, + section: ProjectResourceCanvasCategory, action: ProjectResourceSectionZoomAction, ) => { const key = projectResourceSectionHeightKey({ projectId, mode, section }); diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 521fc1247..58a3edffe 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -13,7 +13,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectResourceCanvasCategory } from '../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, @@ -33,7 +33,7 @@ function position( resourceId: string, x: number, y: number, - section: ProjectResourceCanvasSection = 'art', + section: ProjectResourceCanvasCategory = 'scene', ): ProjectResourceCanvasPosition { return { resourceId, @@ -196,7 +196,7 @@ describe('ResourceDependencyOverlay', () => { it('在统一 world 模式下渲染跨分类引用,并使用稳定的 world 标识', async () => { const graph = graphFixture(); const positions = [ - position('source:one', 0, 0, 'art'), + position('source:one', 0, 0, 'scene'), position('target:one', 220, 0, 'document'), ]; render( @@ -227,10 +227,10 @@ describe('ResourceDependencyOverlay', () => { const graph = graphFixture(); const positions = [ position('source:one', 0, 0, 'document'), - position('source:two', 0, 0, 'art'), + position('source:two', 0, 0, 'scene'), position('source:three', 0, 120, 'document'), - position('target:one', 240, 0, 'art'), - position('target:two', 240, 120, 'art'), + position('target:one', 240, 0, 'scene'), + position('target:two', 240, 120, 'scene'), position('target:three', 240, 0, 'audio'), ]; render( @@ -301,8 +301,8 @@ describe('ResourceDependencyOverlay', () => { producerMappingTruncated: false, }); const positions = [ - position('art:source', 0, 0, 'art'), - position('art:target', 240, 0, 'art'), + position('art:source', 0, 0, 'scene'), + position('art:target', 240, 0, 'scene'), position('version:source', 0, 0, 'version'), position('version:target', 240, 0, 'version'), ]; @@ -331,7 +331,7 @@ describe('ResourceDependencyOverlay', () => { overlayView(graph, positions, new Set(resourceIds)), ); const artOverlay = await screen.findByTestId( - 'resource-dependency-overlay-art', + 'resource-dependency-overlay-scene', ); const versionOverlay = await screen.findByTestId( 'resource-dependency-overlay-version', @@ -357,10 +357,10 @@ describe('ResourceDependencyOverlay', () => { ?.getAttribute('d'); const artViewport = rendered.container.querySelector( - '[data-resource-section-scroll="art"]', + '[data-resource-section-scroll="scene"]', ); if (!artViewport) { - throw new Error('missing art viewport'); + throw new Error('missing scene viewport'); } artViewport.scrollTop = 80; fireEvent.scroll(artViewport); @@ -527,11 +527,11 @@ describe('ResourceDependencyOverlay', () => { { 'data-testid': 'resource-dependency-overlay' }, React.createElement( 'div', - { 'data-resource-section-scroll': 'art' }, + { 'data-resource-section-scroll': 'scene' }, React.createElement( 'div', { - 'data-resource-section-plane': 'art', + 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1.5', }, React.createElement(ResourceDependencyOverlay, { @@ -545,7 +545,7 @@ describe('ResourceDependencyOverlay', () => { 0, ), ], - section: 'art', + section: 'scene', visibleResourceIds: new Set(['source:one', 'target:one']), geometryRevision: '340@1.5', }), @@ -581,11 +581,11 @@ describe('ResourceDependencyOverlay', () => { { 'data-testid': 'resource-dependency-overlay' }, React.createElement( 'div', - { 'data-resource-section-scroll': 'art' }, + { 'data-resource-section-scroll': 'scene' }, React.createElement( 'div', { - 'data-resource-section-plane': 'art', + 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1', }, React.createElement(ResourceDependencyOverlay, { @@ -594,7 +594,7 @@ describe('ResourceDependencyOverlay', () => { position('source:one', 0, 0), position('target:one', 268, 0), ], - section: 'art', + section: 'scene', visibleResourceIds: new Set(['source:one', 'target:one']), cardSizeByResourceId, }), @@ -642,12 +642,12 @@ describe('ResourceDependencyOverlay', () => { if (this.classList.contains('test-resource-content')) { return rect(0, 0, 600, 600); } - if (this.dataset.resourceSectionScroll === 'art') { + if (this.dataset.resourceSectionScroll === 'scene') { return rect(0, 100, 600, 240); } - if (this.dataset.resourceSectionPlane === 'art') { + if (this.dataset.resourceSectionPlane === 'scene') { const viewport = this.closest( - '[data-resource-section-scroll="art"]', + '[data-resource-section-scroll="scene"]', ); return rect(0, 100 - (viewport?.scrollTop ?? 0), 600, 600); } @@ -663,11 +663,11 @@ describe('ResourceDependencyOverlay', () => { }, React.createElement( 'div', - { 'data-resource-section-scroll': 'art' }, + { 'data-resource-section-scroll': 'scene' }, React.createElement( 'div', { - 'data-resource-section-plane': 'art', + 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1', }, React.createElement(ResourceDependencyOverlay, { @@ -676,7 +676,7 @@ describe('ResourceDependencyOverlay', () => { position('source:one', 0, 0), position('target:one', 0, 320), ], - section: 'art', + section: 'scene', visibleResourceIds: new Set(['source:one', 'target:one']), }), ), @@ -698,10 +698,10 @@ describe('ResourceDependencyOverlay', () => { expect(reference.getAttribute('data-offscreen-target')).toBe('true'); const viewport = rendered.container.querySelector( - '[data-resource-section-scroll="art"]', + '[data-resource-section-scroll="scene"]', ); if (!viewport) { - throw new Error('missing art viewport'); + throw new Error('missing scene viewport'); } viewport.scrollTop = 80; fireEvent.scroll(viewport); @@ -733,7 +733,7 @@ describe('ResourceDependencyOverlay', () => { const getBoundingClientRect = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getPagedCanvasRect(this: HTMLElement) { - if (this.dataset.resourceSectionScroll === 'art') { + if (this.dataset.resourceSectionScroll === 'scene') { return { x: 0, y: 0, @@ -754,7 +754,7 @@ describe('ResourceDependencyOverlay', () => { { className: 'game-resource-canvas--dependency' }, React.createElement( 'div', - { 'data-resource-section-scroll': 'art' }, + { 'data-resource-section-scroll': 'scene' }, React.createElement( 'div', { className: 'game-resource-page-world' }, @@ -764,7 +764,7 @@ describe('ResourceDependencyOverlay', () => { position('source:one', 0, 0), position('target:one', 0, 320), ], - section: 'art', + section: 'scene', visibleResourceIds: new Set(['source:one', 'target:one']), canvasViewport, }), @@ -774,7 +774,7 @@ describe('ResourceDependencyOverlay', () => { try { const rendered = render(view({ x: 0, y: 0, scale: 1 })); const overlay = await screen.findByTestId( - 'resource-dependency-overlay-art', + 'resource-dependency-overlay-scene', ); const selector = '[data-edge-kind="asset-reference"]'; await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 36fe691b8..c36c6330f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -334,13 +334,14 @@ async function submitChat(value: string) { // 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含 // 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为 -// `选中资源:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。 +// `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `UI 交互`), +// 这里按模板做精确匹配。 function escapeRegExpLiteral(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function resourceSelectButtonName(label: string) { - return new RegExp(`^选中资源:\\S+ ${escapeRegExpLiteral(label)}$`); + return new RegExp(`^选中资源:.+ ${escapeRegExpLiteral(label)}$`); } function getResourceSelectButton(label: string) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e7c0eb06e..81281c4d0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -92,11 +92,13 @@ function ApprovedGddStartHarness() { async function openResourceBookCategory(label: string) { const categoryByLabel: Record = { - 设计文档: 'document', - 美术资源: 'art', - 音乐音效: 'audio', + 'UI 交互': 'ui-interaction', + 角色与对象: 'character', + 场景与环境: 'scene', + 音频: 'audio', + 文档: 'document', + 待归类: 'unclassified', 项目版本: 'version', - 游戏代码: 'code', }; const category = categoryByLabel[label]; const outline = await screen.findByLabelText('资源栏目大纲'); @@ -320,7 +322,7 @@ export function registerClientHomeTests() { fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('UI 交互'); expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( @@ -486,7 +488,7 @@ export function registerClientHomeTests() { runtimeHarness.emitManifestInvalidated('art-asset-plan'); }); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('UI 交互'); expect( await findResourceSelectButton('runtime-live-hero.png', { timeout: 5_000, @@ -662,7 +664,7 @@ export function registerClientHomeTests() { fireEvent.click(screen.getByRole('button', { name: '项目组' })); pickProjectFromLauncher(secondProjectPath); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('UI 交互'); expect(await findResourceSelectButton('second.png')).not.toBeNull(); await act(async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 04a1bcfea..54e058cd8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -157,11 +157,13 @@ function installResourceCardIntersectionObserver() { async function openResourceBookCategory(label: string) { const categoryByLabel: Record = { - 设计文档: 'document', - 美术资源: 'art', - 音乐音效: 'audio', + 'UI 交互': 'ui-interaction', + 角色与对象: 'character', + 场景与环境: 'scene', + 音频: 'audio', + 文档: 'document', + 待归类: 'unclassified', 项目版本: 'version', - 游戏代码: 'code', }; const category = categoryByLabel[label]; const outline = await screen.findByLabelText('资源栏目大纲'); @@ -389,9 +391,9 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const artCanvas = await screen.findByRole('region', { - name: '美术资源', + name: '角色与对象', }); const readViewport = (canvas: HTMLElement) => canvas @@ -417,10 +419,10 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('button', { name: '按类型' })); const outline = screen.getByLabelText('资源栏目大纲'); - fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ })); - await openResourceBookCategory('美术资源'); + fireEvent.click(within(outline).getByRole('button', { name: /文档/ })); + await openResourceBookCategory('角色与对象'); const typeCanvas = await screen.findByRole('region', { - name: '美术资源', + name: '角色与对象', }); await waitFor(() => expect(readViewport(typeCanvas)).not.toBe(dependencyViewport), @@ -432,18 +434,18 @@ export function registerProjectWorkbenchFoundationTests() { expect(typeViewport).not.toBe(dependencyViewport); fireEvent.click(screen.getByRole('button', { name: '按依赖' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const restoredDependencyCanvas = await screen.findByRole('region', { - name: '美术资源', + name: '角色与对象', }); await waitFor(() => expect(readViewport(restoredDependencyCanvas)).toBe(dependencyViewport), ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const restoredTypeCanvas = await screen.findByRole('region', { - name: '美术资源', + name: '角色与对象', }); await waitFor(() => expect(readViewport(restoredTypeCanvas)).toBe(typeViewport), @@ -452,9 +454,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('tab', { name: '运行' })); expect(screen.getByRole('region', { name: '运行表现层' })).not.toBeNull(); fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const restoredArtCanvas = await screen.findByRole('region', { - name: '美术资源', + name: '角色与对象', }); await waitFor(() => expect(readViewport(restoredArtCanvas)).toBe(typeViewport), @@ -574,19 +576,19 @@ export function registerProjectWorkbenchFoundationTests() { ); expect( manager?.querySelector( - '.game-resource-book-scene-titlebar[data-resource-book-category="document"]', + '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ), ).not.toBeNull(); expect( manager ?.querySelector( - '.game-resource-book-scene-titlebar[data-resource-book-category="document"]', + '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ) ?.getAttribute('style'), ).toContain('opacity: 1'); expect( manager?.querySelector( - '.game-resource-book-scene-card[data-resource-book-category="document"] .game-resource-card', + '.game-resource-book-scene-card[data-resource-book-category="unclassified"] .game-resource-card', ), ).not.toBeNull(); expect(sceneWorld?.style.transform).toContain('scale(1)'); @@ -735,27 +737,35 @@ export function registerProjectWorkbenchFoundationTests() { Array.from(outline.querySelectorAll('strong')).map( (item) => item.textContent, ), - ).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']); + ).toEqual([ + 'UI 交互', + '角色与对象', + '场景与环境', + '音频', + '文档', + '待归类', + '项目版本', + ]); expect(graphResourceIds).toContain('asset:section-code'); - expect(queryResourceSelectButton('section.js')).toBeNull(); + expect(queryResourceSelectButton('section.js')).not.toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); expect(outline.querySelectorAll('small')).toHaveLength(0); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); expect( - screen.getByTestId('resource-dependency-overlay-document'), + screen.getByTestId('resource-dependency-overlay-character'), ).not.toBeNull(); expect( - screen.getByRole('button', { name: /下一页\s*美术资源/ }), + screen.getByRole('button', { name: /下一页\s*场景与环境/ }), ).not.toBeNull(); const documentCardBefore = document.querySelector( - '.game-resource-book-scene-card[data-resource-book-category="document"] .game-resource-card', + '.game-resource-book-scene-card[data-resource-book-category="unclassified"] .game-resource-card', ); const documentTitlebarBefore = document.querySelector( - '.game-resource-book-scene-titlebar[data-resource-book-category="document"]', + '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ); expect(documentCardBefore).not.toBeNull(); expect(documentTitlebarBefore).not.toBeNull(); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); // 真实元素 FLIP:卡片和标题栏在总览与子画布之间是同一批 DOM 节点, // 转场不引入克隆快照层。 expect(documentCardBefore!.isConnected).toBe(true); @@ -790,10 +800,10 @@ export function registerProjectWorkbenchFoundationTests() { }; dispatchViewWheel(); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const documentWorld = dependencyCanvas.querySelector( - '.game-resource-page-canvas[data-resource-section-scroll="document"] [data-resource-viewport]', + '.game-resource-page-canvas[data-resource-section-scroll="unclassified"] [data-resource-viewport]', ); expect(documentWorld).not.toBeNull(); await waitFor(() => @@ -811,10 +821,15 @@ export function registerProjectWorkbenchFoundationTests() { .map(Number); const fittedViewport = readViewport(); const fittedBounds = readFitBounds(); - const expectedDocumentFitScale = Math.min( - RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, - 468 / fittedBounds[2]!, - 268 / fittedBounds[3]!, + // 子画布不再为了塞下整栏把卡片缩到 1:1 以下:拟合结果低于 1 时保持可读的 scale 1。 + // 「待归类」现在同时收下 section.md 与 section.js 两张卡,拟合值本来就小于 1。 + const expectedDocumentFitScale = Math.max( + 1, + Math.min( + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, + 468 / fittedBounds[2]!, + 268 / fittedBounds[3]!, + ), ); expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8); @@ -855,21 +870,21 @@ export function registerProjectWorkbenchFoundationTests() { expect(zoomWheelResult).toBe(false); expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!); const documentViewportAfterZoom = readViewport(); - await openResourceBookCategory('美术资源'); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('角色与对象'); + await openResourceBookCategory('待归类'); await waitFor(() => expect(readViewport()).toEqual(documentViewportAfterZoom), ); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); fireEvent.click( screen.getByRole('button', { - name: /选中资源:设计文档 section\.md/, + name: /选中资源:待归类 section\.md/, }), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); dispatchViewWheel(); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); fireEvent.click( within( document.querySelector( @@ -886,14 +901,14 @@ export function registerProjectWorkbenchFoundationTests() { ?.getAttribute('data-resource-book-transition'), ).toBe('idle'); expect(mainWorld.style.transform).toBe(mainViewportBeforeNavigation); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); const viewportBeforeWheelNavigation = readViewport(); dispatchViewWheel(); await waitFor(() => expect(readViewport()[1]).toBeLessThan(viewportBeforeWheelNavigation[1]!), ); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const zoomAfterViewWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, @@ -915,11 +930,11 @@ export function registerProjectWorkbenchFoundationTests() { viewportBeforeWheelNavigation[2]!, ), ); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const dragCard = screen .getByRole('button', { - name: /选中资源:设计文档 section\.md/, + name: /选中资源:待归类 section\.md/, }) .closest('.game-resource-card'); expect(dragCard).not.toBeNull(); @@ -951,7 +966,9 @@ export function registerProjectWorkbenchFoundationTests() { clientX: 100, clientY: 50, }); - fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + fireEvent.click( + within(outline).getByRole('button', { name: /角色与对象/ }), + ); expect(cardReleasePointerCapture).toHaveBeenCalledWith(91); fireEvent.pointerUp(dragCard!, { pointerId: 91, @@ -964,7 +981,7 @@ export function registerProjectWorkbenchFoundationTests() { command === 'update_local_project_resource_canvas_layout', ), ).toHaveLength(layoutUpdateCountBeforeCancelledDrag); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); const viewportBeforePan = readViewport(); // 与美术画布一致:左键在空白处是框选,平移用中键(或按住空格)。 @@ -1003,19 +1020,23 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect(readViewport()[1]).toBeLessThan(sectionViewportBeforeWheel[1]!), ); - expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); expect( - screen.getByTestId('resource-dependency-overlay-document'), + screen.getByTestId('resource-dependency-overlay-unclassified'), ).not.toBeNull(); - expect(screen.queryByTestId('resource-dependency-overlay-art')).toBeNull(); + expect( + screen.queryByTestId('resource-dependency-overlay-character'), + ).toBeNull(); - fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + fireEvent.click( + within(outline).getByRole('button', { name: /角色与对象/ }), + ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); const typeCanvas = screen.getByLabelText('资源类型视图') as HTMLDivElement; expect( within(typeCanvas).getByRole('button', { name: '复位资源视图' }), ).not.toBeNull(); - expect(screen.getByRole('region', { name: '美术资源' })).not.toBeNull(); + expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); expect( typeCanvas.querySelector('.game-resource-dependency-overlay'), ).toBeNull(); @@ -1033,8 +1054,8 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect(readTypeViewport()).toBeLessThan(typeViewportBeforeWheel), ); - expect(screen.getByRole('region', { name: '美术资源' })).not.toBeNull(); - expect(screen.queryByRole('region', { name: '音乐音效' })).toBeNull(); + expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); + expect(screen.queryByRole('region', { name: '音频' })).toBeNull(); expect(screen.queryByRole('button', { name: /缩小.*分区/ })).toBeNull(); expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull(); }, 10_000); @@ -1103,23 +1124,23 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect( document.querySelectorAll( - '.game-resource-book-scene-card[data-resource-book-category="art"]', + '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT), ); expect(screen.getByText('5 项')).not.toBeNull(); const overviewCardLayers = Array.from( document.querySelectorAll( - '.game-resource-book-scene-card[data-resource-book-category="art"]', + '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).map((card) => Number(card.style.zIndex)); expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); await waitFor(() => expect( document.querySelectorAll( - '.game-resource-book-scene-card.is-expanded[data-resource-book-category="art"]', + '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', ), ).toHaveLength(5), ); @@ -1131,7 +1152,7 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect( document.querySelectorAll( - '.game-resource-book-scene-card[data-resource-book-category="art"]', + '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).toHaveLength(1), ); @@ -1242,6 +1263,18 @@ export function registerProjectWorkbenchFoundationTests() { const outline = await screen.findByLabelText('资源栏目大纲'); const entries = Array.from(outline.querySelectorAll('button')); expect(entries.length).toBeGreaterThan(0); + // 分区轴固定 7 栏:6 类资产功能分类 + 末尾独立的「项目版本」。 + expect( + entries.map((button) => button.querySelector('strong')?.textContent), + ).toEqual([ + 'UI 交互', + '角色与对象', + '场景与环境', + '音频', + '文档', + '待归类', + '项目版本', + ]); // 每条栏目都是"图标 + 栏目文字"的完整入口,不是只有一行小字。 expect( entries.every( @@ -1250,10 +1283,10 @@ export function registerProjectWorkbenchFoundationTests() { (button.querySelector('strong')?.textContent ?? '').trim().length > 0, ), ).toBe(true); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); expect( within(outline) - .getByRole('button', { name: '美术资源' }) + .getByRole('button', { name: '角色与对象' }) .getAttribute('aria-current'), ).toBe('page'); }); @@ -1353,7 +1386,7 @@ export function registerProjectWorkbenchFoundationTests() { const overviewCards = () => document.querySelectorAll( - '.game-resource-book-scene-card[data-resource-book-category="art"]', + '.game-resource-book-scene-card[data-resource-book-category="character"]', ); await waitFor(() => expect(overviewCards()).toHaveLength(2)); @@ -1362,7 +1395,7 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect(overviewCards()).toHaveLength(1)); // 分页画布态:同一条件继续生效,输入框仍可读写。 - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); expect(searchInput().value).toBe('alpha-hero'); await waitFor(() => expect(getResourceSelectButton('alpha-hero.png')).not.toBeNull(), @@ -1425,7 +1458,7 @@ export function registerProjectWorkbenchFoundationTests() { ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const canvas = screen.getByLabelText('资源类型视图') as HTMLDivElement; Object.defineProperties(canvas, { setPointerCapture: { configurable: true, value: vi.fn() }, @@ -1528,7 +1561,7 @@ export function registerProjectWorkbenchFoundationTests() { ).toBeNull(); expect( within(outline) - .getByRole('button', { name: '美术资源' }) + .getByRole('button', { name: '角色与对象' }) .getAttribute('aria-current'), ).toBe('page'); @@ -1576,20 +1609,22 @@ export function registerProjectWorkbenchFoundationTests() { ); const unreadAudioTab = await within(outline).findByRole('button', { - name: '音乐音效,有新资源', + name: '音频,有新资源', }); expect( unreadAudioTab.querySelector('.game-resource-outline-unread'), ).not.toBeNull(); expect( - within(outline).queryByRole('button', { name: '美术资源,有新资源' }), + within(outline).queryByRole('button', { + name: '角色与对象,有新资源', + }), ).toBeNull(); fireEvent.click(unreadAudioTab); await waitFor(() => { expect( within(outline) - .getByRole('button', { name: '音乐音效' }) + .getByRole('button', { name: '音频' }) .getAttribute('aria-current'), ).toBe('page'); expect( @@ -1619,14 +1654,16 @@ export function registerProjectWorkbenchFoundationTests() { }), ); await waitFor(() => { - expect(screen.queryByLabelText('资源栏目大纲')).toBeNull(); - expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4); + // 只登记游戏代码的项目不再被分区轴排除:画布照常分页,卡片落在「待归类」栏目。 + expect(screen.queryByLabelText('资源栏目大纲')).not.toBeNull(); + expect(screen.queryAllByText('暂无已登记资源')).toHaveLength(0); expect( screen .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--paged'), - ).toBe(false); - expect(queryResourceSelectButton('game.js')).toBeNull(); + ).toBe(true); + expect(queryResourceSelectButton('game.js')).not.toBeNull(); + expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); }); }); @@ -1695,7 +1732,7 @@ export function registerProjectWorkbenchFoundationTests() { ); const outline = await screen.findByLabelText('资源栏目大纲'); expect( - within(outline).getByRole('button', { name: '设计文档' }), + within(outline).getByRole('button', { name: '待归类' }), ).not.toBeNull(); const refreshedManifest = { @@ -1721,10 +1758,10 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + fireEvent.click(within(outline).getByRole('button', { name: /待归类/ })); expect( await screen.findByRole('button', { - name: '选中资源:美术资源 local-imported-image.png', + name: '选中资源:待归类 local-imported-image.png', }), ).not.toBeNull(); }); @@ -1796,17 +1833,25 @@ export function registerProjectWorkbenchFoundationTests() { .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--dependency'), ).toBe(false); - expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4); - for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) { + expect(screen.getAllByText('暂无已登记资源')).toHaveLength(7); + for (const label of [ + 'UI 交互', + '角色与对象', + '场景与环境', + '音频', + '文档', + '待归类', + '项目版本', + ]) { expect(screen.getByRole('region', { name: label })).not.toBeNull(); } }; - await screen.findByRole('region', { name: '设计文档' }); + await screen.findByRole('region', { name: 'UI 交互' }); assertOverview(); fireEvent.click(screen.getByRole('button', { name: '按类型' })); assertOverview(); fireEvent.click(screen.getByRole('button', { name: '按依赖' })); - await screen.findByRole('region', { name: '设计文档' }); + await screen.findByRole('region', { name: 'UI 交互' }); assertOverview(); }); @@ -1826,7 +1871,9 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'intro-video', - kind: 'video', + // 视频与角色图必须落在同一栏目才能同屏比较卡片本体与单媒体播放, + // 因此用同为「角色与对象」的 character-animation;显示类型仍按 video/mp4 判为视频。 + kind: 'character-animation', mediaType: 'video/mp4', localPath: 'assets/intro.mp4', source: { kind: 'generated', taskId: 'art-asset-plan' }, @@ -1947,7 +1994,7 @@ export function registerProjectWorkbenchFoundationTests() { ); const showResourcePage = (label: string) => openResourceBookCategory(label); - await showResourcePage('美术资源'); + await showResourcePage('角色与对象'); let heroDetailButton = await findResourceSelectButton('hero.png'); const heroCard = heroDetailButton.closest('.game-resource-card'); expect(heroCard?.textContent).not.toContain('hero.png'); @@ -1971,7 +2018,7 @@ export function registerProjectWorkbenchFoundationTests() { ?.getAttribute('src'), ).toBe('blob:mock-attachment-preview'); }); - await showResourcePage('设计文档'); + await showResourcePage('待归类'); act(() => observer.triggerVisible()); const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/); expect( @@ -1979,7 +2026,7 @@ export function registerProjectWorkbenchFoundationTests() { .closest('.game-resource-card') ?.querySelector('[data-resource-type="文档"]'), ).not.toBeNull(); - await showResourcePage('美术资源'); + await showResourcePage('角色与对象'); expect( invoke.mock.calls.some( ([command, args]) => @@ -2044,7 +2091,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(stableVideoControl).toBe(pauseVideoControl); expect(document.activeElement).toBe(stableVideoControl); - await showResourcePage('音乐音效'); + await showResourcePage('音频'); const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' }); expect( audioControl @@ -2065,7 +2112,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(pause.mock.instances).toContain(video); expect(screen.queryByRole('dialog', { name: 'theme.mp3' })).toBeNull(); - await showResourcePage('美术资源'); + await showResourcePage('角色与对象'); heroDetailButton = await findResourceSelectButton('hero.png'); fireEvent.click(heroDetailButton); const heroToolbar = await screen.findByRole('toolbar', { @@ -2200,7 +2247,7 @@ export function registerProjectWorkbenchFoundationTests() { onProjectsOpen: vi.fn(), }; const rendered = render(React.createElement(ProjectDevelopmentView, props)); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); await waitFor(() => expect(observer.observedCount()).toBe(49)); act(() => { observer.triggerVisible(); @@ -2267,7 +2314,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest: lateManifest, }), ); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); await waitFor(() => expect(getResourceSelectButton('next.png')).not.toBeNull(), ); @@ -2380,7 +2427,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.queryByText('asset:asset-removed')).toBeNull(); fireEvent.click( within(screen.getByLabelText('资源栏目大纲')).getByRole('button', { - name: /美术资源/, + name: /角色与对象/, }), ); const playerCard = getResourceSelectButton('player.png').closest( @@ -2505,27 +2552,29 @@ export function registerProjectWorkbenchFoundationTests() { }; } if (command === 'read_local_project_media_preview') { - if (args?.category === 'art') { - if (args?.relativePath === 'assets/intro.mp4') { - return { - path: 'assets/intro.mp4', - mediaType: 'video/mp4', - byteLen: 128, - dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=', - }; - } + // 媒体读取的 `category` 现在传资源所在的功能栏目(svg 为 ui-interaction、 + // 视频与音频为 unclassified),不能再按旧栏目 `art` 分流,改按路径返回。 + if (args?.relativePath === 'assets/bgm.mp3') { return { - path: 'assets/icon.svg', - mediaType: 'image/svg+xml', - byteLen: 48, - dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+', + path: 'assets/bgm.mp3', + mediaType: 'audio/mpeg', + byteLen: 1024, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + if (args?.relativePath === 'assets/intro.mp4') { + return { + path: 'assets/intro.mp4', + mediaType: 'video/mp4', + byteLen: 128, + dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=', }; } return { - path: 'assets/bgm.mp3', - mediaType: 'audio/mpeg', - byteLen: 1024, - dataUrl: 'data:audio/mpeg;base64,SUQz', + path: 'assets/icon.svg', + mediaType: 'image/svg+xml', + byteLen: 48, + dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+', }; } if (command === 'save_local_project_asset_file') { @@ -2555,33 +2604,39 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); fireEvent.click(await findResourceSelectButton('design.md')); // 选中资源浮出的是画布工具条;"资源详情"面板已随 C3 交互合同删除。 expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); - await openResourceBookCategory('美术资源'); - fireEvent.click(getResourceSelectButton('icon.svg')); + await openResourceBookCategory('UI 交互'); + // 栏目标签「UI 交互」自带空格,harness 里用 `\S+` 拼出的选中按钮正则匹配不到, + // 这里改按完整可访问名查询同一条选中按钮。 + fireEvent.click( + screen.getByRole('button', { name: '选中资源:UI 交互 icon.svg' }), + ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); + // 视频(animation)与音频(bgm)都不在 canonical kind 目录里,落在「待归类」栏目。 + await openResourceBookCategory('待归类'); fireEvent.click( screen.getByRole('button', { - name: '选中资源:美术资源 intro.mp4', + name: '选中资源:待归类 intro.mp4', }), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); - await openResourceBookCategory('音乐音效'); + await openResourceBookCategory('待归类'); fireEvent.click( screen.getByRole('button', { - name: '选中资源:音乐音效 bgm.mp3', + name: '选中资源:待归类 bgm.mp3', }), ); expect( invoke.mock.calls.some( ([command, args]) => command === 'read_local_project_media_preview' && - args?.category === 'audio', + args?.relativePath === 'assets/bgm.mp3', ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), @@ -2634,7 +2689,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(saveCallCount()).toBe(2); }); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); fireEvent.click(getResourceSelectButton('blocked.md')); expect( getResourceSelectButton('blocked.md').getAttribute('aria-pressed'), @@ -2715,7 +2770,7 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('音乐音效'); + await openResourceBookCategory('音频'); fireEvent.click(await findResourceSelectButton('focus.mp3')); expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull(); @@ -2962,9 +3017,9 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); let overlay = await screen.findByTestId( - 'resource-dependency-overlay-document', + 'resource-dependency-overlay-unclassified', ); await waitFor(() => { expect( @@ -2997,10 +3052,12 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('button', { name: '按类型' })); expect( - screen.queryByTestId('resource-dependency-overlay-document'), + screen.queryByTestId('resource-dependency-overlay-unclassified'), ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '按依赖' })); - overlay = await screen.findByTestId('resource-dependency-overlay-document'); + overlay = await screen.findByTestId( + 'resource-dependency-overlay-unclassified', + ); const dependencyWorld = dependencyCanvas.querySelector( '.game-resource-canvas-content', @@ -3086,7 +3143,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(sourceCardContainer!); fireEvent.click(targetCard.closest('.game-resource-card')!); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); - overlay = await screen.findByTestId('resource-dependency-overlay-document'); + overlay = await screen.findByTestId( + 'resource-dependency-overlay-unclassified', + ); sourceCard = getResourceSelectButton('spec-source.json'); await waitFor(() => expect(overlay.querySelector(referenceSelector)).not.toBeNull(), @@ -3131,7 +3190,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(true); expect( - screen.queryByTestId('resource-dependency-overlay-document'), + screen.queryByTestId('resource-dependency-overlay-unclassified'), ).toBeNull(); }); }); @@ -3169,21 +3228,21 @@ export function registerProjectWorkbenchFoundationTests() { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'code', - section: 'code', + section: 'unclassified', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'art-a', - section: 'art', + section: 'scene', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'art-b', - section: 'art', + section: 'scene', x: 0, y: 144, manuallyPlaced: false, @@ -3194,13 +3253,13 @@ export function registerProjectWorkbenchFoundationTests() { React.createElement(ResourceDependencyOverlay, { graph, positions, - section: 'art', + section: 'scene', visibleResourceIds: new Set(['art-a', 'art-b']), }), ); const overlay = await screen.findByTestId( - 'resource-dependency-overlay-art', + 'resource-dependency-overlay-scene', ); await waitFor(() => { const edgeById = (edgeId: string) => @@ -3252,14 +3311,14 @@ export function registerProjectWorkbenchFoundationTests() { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'resource-a', - section: 'art', + section: 'scene', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'resource-b', - section: 'art', + section: 'scene', x: 0, y: 144, manuallyPlaced: false, @@ -3309,12 +3368,12 @@ export function registerProjectWorkbenchFoundationTests() { if (this.classList.contains('test-resource-content')) { return rect(0, 0, 600, 800); } - if (this.dataset.resourceSectionScroll === 'art') { + if (this.dataset.resourceSectionScroll === 'scene') { return rect(0, 100, 600, 300); } - if (this.dataset.resourceSectionPlane === 'art') { + if (this.dataset.resourceSectionPlane === 'scene') { const viewport = this.closest( - '[data-resource-section-scroll="art"]', + '[data-resource-section-scroll="scene"]', ); return rect( -(viewport?.scrollLeft ?? 0), @@ -3343,18 +3402,18 @@ export function registerProjectWorkbenchFoundationTests() { React.createElement( 'div', { - 'data-resource-section-scroll': 'art', + 'data-resource-section-scroll': 'scene', }, React.createElement( 'div', { - 'data-resource-section-plane': 'art', + 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1', }, React.createElement(ResourceDependencyOverlay, { graph, positions, - section: 'art', + section: 'scene', visibleResourceIds: new Set(['resource-a', 'resource-b']), geometryRevision: '340@1', }), @@ -3378,16 +3437,16 @@ export function registerProjectWorkbenchFoundationTests() { expect(observerCount).toBe(1); expect( overlay - .querySelector('[data-testid="resource-dependency-overlay-art"]') + .querySelector('[data-testid="resource-dependency-overlay-scene"]') ?.parentElement?.getAttribute('data-resource-section-plane'), - ).toBe('art'); + ).toBe('scene'); expect(overlay.querySelector('clipPath')).toBeNull(); const viewport = rendered.container.querySelector( - '[data-resource-section-scroll="art"]', + '[data-resource-section-scroll="scene"]', ); if (!viewport) { - throw new Error('missing art section viewport'); + throw new Error('missing scene section viewport'); } const removeViewportListener = vi.spyOn(viewport, 'removeEventListener'); const referenceBeforeScroll = overlay.querySelector(referenceSelector); @@ -3409,7 +3468,7 @@ export function registerProjectWorkbenchFoundationTests() { ); expect( overlay - .querySelector('[data-testid="resource-dependency-overlay-art"]') + .querySelector('[data-testid="resource-dependency-overlay-scene"]') ?.getAttribute('data-logical-viewport'), ).toBe('0,80,600,300'); }); @@ -3496,12 +3555,12 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('文档'); await waitFor(() => expect(resolveGraph).not.toBeNull()); expect(dependencyLayoutReads).toBe(0); expect( screen.queryByRole('button', { - name: '选中资源:设计文档 延迟依赖图回执', + name: '选中资源:文档 延迟依赖图回执', }), ).toBeNull(); @@ -3511,7 +3570,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '选中资源:设计文档 延迟依赖图回执', + name: '选中资源:文档 延迟依赖图回执', }), ).not.toBeNull(); expect(dependencyLayoutReads).toBe(1); @@ -3582,9 +3641,9 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }); const view = render(renderView([firstResult])); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('文档'); const firstCardButton = await screen.findByRole('button', { - name: '选中资源:设计文档 稳定依赖图回执', + name: '选中资源:文档 稳定依赖图回执', }); const firstCard = firstCardButton.closest('.game-resource-card'); expect(firstCard).not.toBeNull(); @@ -3611,7 +3670,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(firstCard?.isConnected).toBe(true); expect( screen.getByRole('button', { - name: '选中资源:设计文档 稳定依赖图回执', + name: '选中资源:文档 稳定依赖图回执', }), ).not.toBeNull(); @@ -3621,7 +3680,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '选中资源:设计文档 刷新期间新增回执', + name: '选中资源:文档 刷新期间新增回执', }), ).not.toBeNull(); expect(firstCard?.isConnected).toBe(true); @@ -3673,21 +3732,21 @@ export function registerProjectWorkbenchFoundationTests() { const existingPositions: ProjectResourceCanvasPosition[] = [ { resourceId: 'asset:truncated-depth-0', - section: 'document', + section: 'unclassified', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'asset:truncated-depth-1', - section: 'document', + section: 'unclassified', x: slotWidth, y: 0, manuallyPlaced: false, }, { resourceId: 'asset:truncated-depth-2', - section: 'document', + section: 'unclassified', x: slotWidth * 2, y: 0, manuallyPlaced: false, @@ -3831,7 +3890,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await waitFor(() => expect(layoutReads).toBe(1)); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('待归类'); const cards = [0, 1, 2].map((depth) => getResourceSelectButton(`truncated-depth-${depth}.json`).closest( '.game-resource-card', @@ -3925,9 +3984,9 @@ export function registerProjectWorkbenchFoundationTests() { } renderWorkbench(); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('文档'); const cardButton = await screen.findByRole('button', { - name: '选中资源:设计文档 布局持久化回执', + name: '选中资源:文档 布局持久化回执', }); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); @@ -4001,10 +4060,10 @@ export function registerProjectWorkbenchFoundationTests() { cleanup(); renderWorkbench(); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('文档'); const restoredCard = ( await screen.findByRole('button', { - name: '选中资源:设计文档 布局持久化回执', + name: '选中资源:文档 布局持久化回执', }) ).closest('.game-resource-card'); await waitFor(() => { @@ -4061,7 +4120,7 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const cardButton = await findResourceSelectButton('clickable.png'); fireEvent.pointerDown(cardButton, { pointerId: 7, @@ -4144,7 +4203,7 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); const cardButton = await findResourceSelectButton('cancelable.png'); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); @@ -4244,7 +4303,7 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }), ); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('UI 交互'); await waitFor(() => expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true), ); @@ -4321,10 +4380,10 @@ export function registerProjectWorkbenchFoundationTests() { expect( await screen.findByText('布局保存失败,已保留当前会话布局'), ).not.toBeNull(); - await openResourceBookCategory('设计文档'); + await openResourceBookCategory('文档'); expect( await screen.findByRole('button', { - name: '选中资源:设计文档 自动排版失败回执', + name: '选中资源:文档 自动排版失败回执', }), ).not.toBeNull(); }); @@ -4626,7 +4685,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click(screen.getByRole('button', { name: '按类型' })); - await openResourceBookCategory('美术资源'); + await openResourceBookCategory('角色与对象'); expect(screen.getByLabelText('资源类型视图')).not.toBeNull(); fireEvent.change(screen.getByLabelText('搜索项目资源'), { target: { value: 'hero.png' }, @@ -4679,10 +4738,11 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - await openResourceBookCategory('美术资源'); - const candidate = await findResourceSelectButton( - 'ui-prototype.png(待视觉验收)', - ); + await openResourceBookCategory('UI 交互'); + // 同上前一条:栏目标签「UI 交互」带空格,按完整可访问名查询选中按钮。 + const candidate = await screen.findByRole('button', { + name: '选中资源:UI 交互 ui-prototype.png(待视觉验收)', + }); const candidateCard = candidate.closest('.game-resource-card'); expect(candidateCard?.textContent).not.toContain('画板 · 候选界面图'); expect(candidateCard?.textContent).not.toContain('assets/ui-prototype.png'); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 597ea9ee2..9c8b24b9b 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -490,7 +490,7 @@ describe('project resource live canvas integration', () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); - fireEvent.click(screen.getByRole('button', { name: '打开美术资源' })); + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', @@ -566,7 +566,7 @@ describe('project resource live canvas integration', () => { const { deriveCalls } = installTauri(); render(); - fireEvent.click(screen.getByRole('button', { name: '打开美术资源' })); + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index f166193bf..1b8b04791 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -114,26 +114,28 @@ describe('项目资源投影', () => { expect(resources.map(({ id, category }) => ({ id, category }))).toEqual( expect.arrayContaining([ + // 非 manifest 资源没有功能分类事实源,统一落在「待归类」。 { id: 'task:design-foundation:memory/game-design.md', - category: 'document', + category: 'unclassified', }, - { id: 'asset:game-entry', category: 'code' }, + // kind `game-entry` 不在 canonical 目录里,按 canonical 兜底 `image` 归「待归类」。 + { id: 'asset:game-entry', category: 'unclassified' }, { id: 'task:design-foundation:assets/ui-preview.svg', - category: 'art', + category: 'unclassified', }, - { id: 'asset:registered-bgm', category: 'audio' }, - { id: 'asset:character-animation', category: 'art' }, - { id: 'attachment:imports/rules.yaml', category: 'document' }, - { id: 'attachment:imports/voice.ogg', category: 'audio' }, + { id: 'asset:registered-bgm', category: 'unclassified' }, + { id: 'asset:character-animation', category: 'character' }, + { id: 'attachment:imports/rules.yaml', category: 'unclassified' }, + { id: 'attachment:imports/voice.ogg', category: 'unclassified' }, { id: 'agent-result:design-foundation:final-run', category: 'document', }, { id: 'task:audio-asset-plan:audio/unregistered-bgm.wav', - category: 'audio', + category: 'unclassified', }, { id: 'version:version-1', category: 'version' }, ]), @@ -293,7 +295,6 @@ describe('项目资源投影', () => { it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => { expect( projectResourceTypeLabel({ - category: 'version', subtype: 'project-version', path: 'versions/v1', mediaType: '正式项目版本', @@ -301,7 +302,6 @@ describe('项目资源投影', () => { ).toBe('项目版本'); expect( projectResourceTypeLabel({ - category: 'document', subtype: 'agent-result', path: '专业 Agent 文本回执', mediaType: 'Agent 历史文本回执', @@ -309,7 +309,6 @@ describe('项目资源投影', () => { ).toBe('Agent 回执'); expect( projectResourceTypeLabel({ - category: 'document', subtype: 'task-artifact', path: 'memory/plan.md', mediaType: '项目文档', @@ -317,7 +316,6 @@ describe('项目资源投影', () => { ).toBe('文档'); expect( projectResourceTypeLabel({ - category: 'audio', subtype: 'task-artifact', path: 'audio/theme.wav', mediaType: '音乐音效产物', @@ -325,23 +323,22 @@ describe('项目资源投影', () => { ).toBe('音频'); expect( projectResourceTypeLabel({ - category: 'art', subtype: 'task-artifact', path: 'assets/hero.svg', mediaType: '美术产物', }), ).toBe('SVG'); + // 任务产物准入时扩展名已经判定过类型;无法判定的产物不会进入画布, + // 这里仍给出稳定的兜底文案而不是按旧栏目硬猜类型。 expect( projectResourceTypeLabel({ - category: 'art', subtype: 'task-artifact', path: 'assets/unknown', mediaType: '美术产物', }), - ).toBe('图片'); + ).toBe('任务产物'); expect( projectResourceTypeLabel({ - category: 'art', subtype: 'character', path: 'assets/hero.svg', mediaType: 'image/svg+xml', @@ -349,7 +346,6 @@ describe('项目资源投影', () => { ).toBe('SVG'); expect( projectResourceTypeLabel({ - category: 'art', subtype: 'video', path: 'assets/intro.mp4', mediaType: 'video/mp4', @@ -357,7 +353,6 @@ describe('项目资源投影', () => { ).toBe('视频'); expect( projectResourceTypeLabel({ - category: 'art', subtype: 'character', path: 'assets/hero.png', mediaType: 'image/png', @@ -365,7 +360,6 @@ describe('项目资源投影', () => { ).toBe('图片'); expect( projectResourceTypeLabel({ - category: 'document', subtype: 'attachment', path: 'docs/rules.yaml', mediaType: 'application/yaml', @@ -373,15 +367,13 @@ describe('项目资源投影', () => { ).toBe('文档'); expect( projectResourceTypeLabel({ - category: 'document', - subtype: 'unknown', + subtype: 'attachment', path: 'data/blob', mediaType: 'application/octet-stream', }), - ).toBe('文档'); + ).toBe('未知'); expect( projectResourceTypeLabel({ - category: 'code', subtype: 'source', path: 'game/main.ts', mediaType: 'text/typescript', @@ -389,19 +381,10 @@ describe('项目资源投影', () => { ).toBe('游戏代码'); expect( projectResourceTypeLabel({ - category: 'art', subtype: 'unknown', path: 'assets/blob', mediaType: 'application/octet-stream', }), - ).toBe('图片'); - expect( - projectResourceTypeLabel({ - category: 'unsupported' as never, - subtype: 'unknown', - path: 'data/blob', - mediaType: 'application/octet-stream', - }), ).toBe('未知'); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index 4006d9f4d..c73573fd8 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -13,7 +13,7 @@ import type { ProjectResource } from '../src/view/project-development/resourcePr function resource(id: string): ProjectResource { return { id, - category: 'art', + category: 'unclassified', subtype: 'image', label: id, path: `assets/${id}.png`, @@ -133,13 +133,13 @@ describe('buildResourceBookScenePlan', () => { resources.map((item) => [item.id, { width: 180, height: 128 }]), ); const base = { - visibleCategoryOrder: ['art'] as const, - resourcesByCategory: new Map([['art', resources]]), - overviewRects: new Map([['art', rect]]), + 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([['art', { x: 0, y: 64 }]]), + categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]), cardsReady: true, }; @@ -174,29 +174,36 @@ describe('buildResourceBookScenePlan', () => { it('keeps other categories mounted for a fade-out while entering', () => { const plan = buildResourceBookScenePlan({ ...base, - visibleCategoryOrder: ['art', 'code'], + visibleCategoryOrder: ['unclassified', 'scene'], resourcesByCategory: new Map([ - ['art', resources], - ['code', [resource('code-0')]], + ['unclassified', resources], + ['scene', [resource('code-0')]], ]), overviewRects: new Map([ - ['art', rect], - ['code', { ...rect, left: 500 }], + ['unclassified', rect], + ['scene', { ...rect, left: 500 }], ]), - state: { view: 'child', category: 'art', phase: 'entering', token: 1 }, + state: { + view: 'child', + category: 'unclassified', + phase: 'entering', + token: 1, + }, }); - const art = plan.find((group) => group.category === 'art')!; - const code = plan.find((group) => group.category === 'code')!; - expect(art.cards.every((card) => card.presentation === 'child')).toBe(true); - expect(art.cards).toHaveLength(4); - expect(art.titlebarActive).toBe(true); - // 其他栏目留在 DOM 里淡出,而不是直接卸载。 - expect(code.cards.every((card) => card.presentation === 'overview')).toBe( + 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(code.titlebarActive).toBe(false); - expect(code.titlebarOpacity).toBe(0); + 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', () => { @@ -208,7 +215,7 @@ describe('buildResourceBookScenePlan', () => { phase: 'returning-main', token: 2, }, - exitingCategory: 'art', + exitingCategory: 'unclassified', }); const cards = plan[0]!.cards; @@ -228,7 +235,7 @@ describe('buildResourceBookScenePlan', () => { exitingCards: [ { key: resourceBookSceneCardKey('art-3'), - category: 'art', + category: 'unclassified', resource: resources[3]!, presentation: 'exiting', stackIndex: 0, @@ -246,7 +253,7 @@ describe('buildResourceBookScenePlan', () => { }, { key: resourceBookSceneCardKey('art-0'), - category: 'art', + category: 'unclassified', resource: resources[0]!, presentation: 'exiting', stackIndex: 0, diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index 4069828c9..7a47f35b7 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -37,15 +37,15 @@ describe('resource book state machine', () => { }); const switching = resourceBookReducer(current, { type: 'open-category', - category: 'art', + category: 'scene', token: 2, }); expect(switching).toMatchObject({ view: 'child', - category: 'art', + category: 'scene', phase: 'entering', }); - expect(resourceBookSceneCategory(switching)).toBe('art'); + expect(resourceBookSceneCategory(switching)).toBe('scene'); }); it('marks a return to the main canvas separately from a child enter transition', () => { @@ -73,7 +73,7 @@ describe('resource book state machine', () => { }); const switching = resourceBookReducer(entering, { type: 'open-category', - category: 'art', + category: 'scene', token: 2, }); const main = resourceBookReducer(switching, { @@ -97,7 +97,7 @@ describe('resource book state machine', () => { }); const switched = resourceBookReducer(entering, { type: 'open-category', - category: 'art', + category: 'scene', token: 2, }); expect( @@ -109,18 +109,20 @@ describe('resource book state machine', () => { describe('resource book card presentation', () => { it('uses the overview stack on the steady main canvas', () => { expect( - resourceBookCategoryCardPresentation(initialResourceBookState, 'art'), + 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: 'art', + category: 'scene', token: 1, }); - expect(resourceBookCategoryCardPresentation(entering, 'art')).toBe('child'); + expect(resourceBookCategoryCardPresentation(entering, 'scene')).toBe( + 'child', + ); expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe( 'overview', ); @@ -130,13 +132,13 @@ describe('resource book card presentation', () => { const steady = resourceBookReducer( resourceBookReducer(initialResourceBookState, { type: 'open-category', - category: 'art', + category: 'scene', token: 1, }), { type: 'finish-transition', token: 1 }, ); - expect(resourceBookCategoryCardPresentation(steady, 'art')).toBe('child'); + expect(resourceBookCategoryCardPresentation(steady, 'scene')).toBe('child'); expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull(); }); @@ -144,13 +146,13 @@ describe('resource book card presentation', () => { const returning = resourceBookReducer( resourceBookReducer(initialResourceBookState, { type: 'open-category', - category: 'art', + category: 'scene', token: 1, }), { type: 'return-to-main', token: 2 }, ); - expect(resourceBookCategoryCardPresentation(returning, 'art')).toBe( + expect(resourceBookCategoryCardPresentation(returning, 'scene')).toBe( 'overview', ); expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCodeOnlyProject.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCodeOnlyProject.test.tsx new file mode 100644 index 000000000..0c7329d17 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCodeOnlyProject.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + cleanup, + createGameCreationAppManifest, + createGameCreationAppSeedTasks, + ProjectDevelopmentView, + React, + render, + screen, + within, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/workbench-code-only-project'; + +function installResourceCardIntersectionObserver() { + class ResourceCardIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + readonly observed = new Set(); + + constructor(readonly callback: IntersectionObserverCallback) {} + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.observed.clear(); + } + + takeRecords() { + return []; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + value: ResourceCardIntersectionObserver, + }); +} + +/** + * 只登记游戏代码的项目:分区轴上没有代码栏目,代码资产(manifest 资产与已完成任务登记 + * 的产物)按资产功能分类落入「待归类」。旧四栏目口径会把它们整体排除在可见栏目之外, + * 于是资源签名非空、画布已进入分页态,却四栏全空、一张卡都不显示。 + */ +function createCodeOnlyManifest(): GameCreationAppManifest { + const manifest = createGameCreationAppManifest( + 'workbench-code-only', + '只登记游戏代码的项目', + ); + manifest.assets = [ + { + id: 'asset-game-entry', + kind: 'code', + mediaType: 'text/html', + localPath: 'game/index.html', + source: { kind: 'generated' }, + }, + ]; + manifest.tasks = createGameCreationAppSeedTasks().map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const, artifacts: ['game/main.js'] } + : task, + ); + return manifest; +} + +function renderWorkbench(manifest: GameCreationAppManifest) { + return render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + onPlay: vi.fn(), + }), + ); +} + +describe('只登记游戏代码的项目分区', () => { + afterEach(() => { + cleanup(); + }); + + test('代码资产落在待归类栏目并渲染出卡片,而不是四栏全空', async () => { + installResourceCardIntersectionObserver(); + renderWorkbench(createCodeOnlyManifest()); + + const outline = await screen.findByLabelText('资源栏目大纲'); + expect( + within(outline).getByRole('button', { name: /待归类/ }), + ).not.toBeNull(); + // 项目版本仍然是末尾独立栏目,代码栏目不再存在。 + expect( + within(outline).getByRole('button', { name: /项目版本/ }), + ).not.toBeNull(); + + // 默认停留在顺序里第一个非空栏目,也就是代码资产所在的「待归类」。 + expect( + await screen.findByRole('region', { name: '待归类' }), + ).not.toBeNull(); + + // manifest 资产与已完成任务的代码产物都必须真的渲染成卡片。 + await screen.findByRole('button', { + name: /选中资源:待归类 index\.html/, + }); + await screen.findByRole('button', { + name: /选中资源:待归类 main\.js/, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts index 30fb4b715..e90517b23 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts @@ -4,7 +4,7 @@ import { createResourceCanvasPageCategorySignature } from '../src/view/project-d describe('resource canvas page wheel controller', () => { it('invalidates page categories for same-count category changes without reacting to resource order', () => { - const original = [{ category: 'document' }, { category: 'art' }]; + const original = [{ category: 'document' }, { category: 'scene' }]; const reordered = [...original].reverse(); const changed = [{ category: 'document' }, { category: 'audio' }]; diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index db6b2df22..434d2c8f6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -19,6 +19,7 @@ import { RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, + RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasContentBounds, resourceCanvasImageCardSize, @@ -38,7 +39,7 @@ function resource( subtype: 'default', dependencyDepth, label: id, - mediaType: category === 'art' ? 'image/png' : 'text/markdown', + mediaType: category === 'scene' ? 'image/png' : 'text/markdown', }; } @@ -101,9 +102,9 @@ describe('resource canvas layout model', () => { it('creates non-overlapping defaults and keeps the two modes independent', () => { const resources = [ - resource('a', 'art', 0), - resource('b', 'art', 0), - resource('c', 'art', 1), + resource('a', 'scene', 0), + resource('b', 'scene', 0), + resource('c', 'scene', 1), ]; const dependency = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-1', 'dependency'), @@ -219,12 +220,12 @@ describe('resource canvas layout model', () => { }, ]; const reconciled = reconcileResourceCanvasLayout(layout, [ - resource('asset-a', 'art'), + resource('asset-a', 'scene'), ]); expect(reconciled.layout.positions).toEqual([ { resourceId: 'asset-a', - section: 'art', + section: 'scene', x: 0, y: 0, manuallyPlaced: false, @@ -232,24 +233,111 @@ describe('resource canvas layout model', () => { ]); }); + it('栏目顺序固定为 6 类资产分类加末尾的项目版本栏目', () => { + expect(RESOURCE_CANVAS_SECTION_ORDER).toEqual([ + 'ui-interaction', + 'character', + 'scene', + 'audio', + 'document', + 'unclassified', + 'version', + ]); + }); + + it('读时归并旧栏目 sidecar:坐标与手动标记原样保留、section 改写并判定为变化', () => { + const source = createEmptyResourceCanvasLayout('project-legacy', 'type'); + source.positions = [ + { + resourceId: 'legacy-art', + section: 'art', + x: -32, + y: 64, + manuallyPlaced: true, + }, + { + resourceId: 'legacy-code', + section: 'code', + x: 180, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'legacy-version', + section: 'version', + x: 12, + y: 34, + manuallyPlaced: true, + }, + ]; + + const reconciled = reconcileResourceCanvasLayout(source, [ + { ...resource('legacy-art', 'ui-interaction'), subtype: 'icon' }, + { ...resource('legacy-code', 'unclassified'), subtype: 'code' }, + { ...resource('legacy-version', 'version'), subtype: 'project-version' }, + ]); + + // 改写后的布局与 sidecar 逐项不同:既有协调路径据此写回一次新分区。 + expect(reconciled.changed).toBe(true); + expect(reconciled.layout.positions).toEqual([ + { + resourceId: 'legacy-art', + section: 'ui-interaction', + x: -32, + y: 64, + manuallyPlaced: true, + }, + { + resourceId: 'legacy-code', + section: 'unclassified', + x: 180, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'legacy-version', + section: 'version', + x: 12, + y: 34, + manuallyPlaced: true, + }, + ]); + }); + + it('只登记游戏代码的项目落在待归类栏目而不是被分区轴排除', () => { + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-code-only', 'type'), + [ + resource('asset:game-entry', 'unclassified'), + resource('task:code-prototype:game/main.js', 'unclassified'), + ], + ).layout; + + expect(RESOURCE_CANVAS_SECTION_ORDER).toContain('unclassified'); + expect(layout.positions).toHaveLength(2); + expect( + new Set(layout.positions.map((position) => position.section)), + ).toEqual(new Set(['unclassified'])); + }); + it('sorts type defaults by subtype before media type and label with an id fallback', () => { const typeLayout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-type-order', 'type'), [ { - ...resource('ui-prototype', 'art'), + ...resource('ui-prototype', 'scene'), subtype: 'ui-prototype', mediaType: 'image/png', label: '甲界面', }, { - ...resource('art-spritesheet-b', 'art'), + ...resource('art-spritesheet-b', 'scene'), subtype: 'art-spritesheet', mediaType: 'image/png', label: '乙图集', }, { - ...resource('art-spritesheet-a', 'art'), + ...resource('art-spritesheet-a', 'scene'), subtype: 'art-spritesheet', mediaType: 'image/png', label: '乙图集', @@ -282,11 +370,11 @@ describe('resource canvas layout model', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-clusters', 'dependency'), [ - resource('art:canvas', 'art', 0), - resource('art:wireframe', 'art', 1), - resource('art:character', 'art', 0), - resource('art:screen', 'art', 1), - resource('art:unrelated', 'art', 0), + resource('art:canvas', 'scene', 0), + resource('art:wireframe', 'scene', 1), + resource('art:character', 'scene', 0), + resource('art:screen', 'scene', 1), + resource('art:unrelated', 'scene', 0), ], dependencyTopology([ { @@ -321,8 +409,8 @@ describe('resource canvas layout model', () => { 'dependency', ), [ - resource('art:a', 'art', 0), - resource('art:b', 'art', 0), + resource('art:a', 'scene', 0), + resource('art:b', 'scene', 0), resource('document:a', 'document', 0), ], dependencyTopology( @@ -355,10 +443,10 @@ describe('resource canvas layout model', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-crossings', 'dependency'), [ - resource('source:bottom', 'art', 0), - resource('source:top', 'art', 0), - resource('target:first', 'art', 1), - resource('target:second', 'art', 1), + resource('source:bottom', 'scene', 0), + resource('source:top', 'scene', 0), + resource('target:first', 'scene', 1), + resource('target:second', 'scene', 1), ], dependencyTopology( [ @@ -392,10 +480,10 @@ describe('resource canvas layout model', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-balanced-diamond', 'dependency'), [ - resource('source', 'art', 0), - resource('middle:upper', 'art', 1), - resource('middle:lower', 'art', 1), - resource('target', 'art', 2), + resource('source', 'scene', 0), + resource('middle:upper', 'scene', 1), + resource('middle:lower', 'scene', 1), + resource('target', 'scene', 2), ], dependencyTopology([ { sourceResourceId: 'source', targetResourceId: 'middle:upper' }, @@ -431,10 +519,10 @@ describe('resource canvas layout model', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-cycles', 'dependency'), [ - resource('art:cycle-a', 'art', 2), - resource('art:cycle-b', 'art', 2), - resource('art:after-cycle', 'art', 3), - resource('art:self', 'art', 0), + resource('art:cycle-a', 'scene', 2), + resource('art:cycle-b', 'scene', 2), + resource('art:after-cycle', 'scene', 3), + resource('art:self', 'scene', 0), resource('document:brief', 'document', 0), ], dependencyTopology([ @@ -472,10 +560,10 @@ describe('resource canvas layout model', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-cycle-contiguity', 'dependency'), [ - resource('art:cycle-a', 'art', 2), - resource('art:cycle-b', 'art', 2), - resource('art:sibling', 'art', 2), - resource('art:after', 'art', 3), + resource('art:cycle-a', 'scene', 2), + resource('art:cycle-b', 'scene', 2), + resource('art:sibling', 'scene', 2), + resource('art:after', 'scene', 3), ], dependencyTopology([ { sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' }, @@ -497,9 +585,9 @@ describe('resource canvas layout model', () => { it('is deterministic, does not modify type placement, and avoids historical manual positions', () => { const resources = [ - resource('art:source', 'art', 0), - resource('art:target', 'art', 1), - resource('art:other', 'art', 1), + resource('art:source', 'scene', 0), + resource('art:target', 'scene', 1), + resource('art:other', 'scene', 1), ]; const topology = dependencyTopology([ { sourceResourceId: 'art:source', targetResourceId: 'art:target' }, @@ -511,7 +599,7 @@ describe('resource canvas layout model', () => { source.positions = [ { resourceId: 'art:other', - section: 'art', + section: 'scene', x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, y: 0, manuallyPlaced: true, @@ -547,10 +635,10 @@ describe('resource canvas layout model', () => { it('saturates super-deep dependency columns and avoids collisions within the coordinate contract', () => { const resources = [ - resource('art:depth-4385', 'art', 4_385), - resource('art:depth-4386-a', 'art', 4_386), - resource('art:depth-4386-b', 'art', 4_386), - resource('art:depth-9000', 'art', 9_000), + resource('art:depth-4385', 'scene', 4_385), + resource('art:depth-4386-a', 'scene', 4_386), + resource('art:depth-4386-b', 'scene', 4_386), + resource('art:depth-9000', 'scene', 9_000), ]; const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'), @@ -616,7 +704,7 @@ describe('resource canvas layout model', () => { 'reconciles 4096 resources in %s mode within the bounded layout budget', (mode) => { const resources = Array.from({ length: 4096 }, (_, index) => ({ - ...resource(`resource-${index.toString().padStart(4, '0')}`, 'art'), + ...resource(`resource-${index.toString().padStart(4, '0')}`, 'scene'), dependencyDepth: index % 64, mediaType: `image/type-${index % 16}`, })); @@ -642,7 +730,7 @@ describe('resource canvas layout model', () => { const resources = Array.from({ length: 4096 }, (_, index) => resource( `resource-${index.toString().padStart(4, '0')}`, - 'art', + 'scene', index % 64, ), ); @@ -687,7 +775,7 @@ describe('resource canvas variable card geometry', () => { it('keeps non-images and images without metadata at the fixed fallback size', () => { const sizes = createResourceCanvasCardSizeByResourceId( - [resource('image', 'art'), resource('document', 'document')], + [resource('image', 'scene'), resource('document', 'document')], new Map([ ['image', { pixelWidth: 1920, pixelHeight: 1080 }], ['document', { pixelWidth: 1920, pixelHeight: 1080 }], @@ -698,14 +786,17 @@ describe('resource canvas variable card geometry', () => { expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); expect( createResourceCanvasCardSizeByResourceId( - [resource('pending-image', 'art')], + [resource('pending-image', 'scene')], new Map(), ).get('pending-image'), ).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); }); it('uses actual widths and heights for type placement collision checks', () => { - const resources = [resource('wide-a', 'art'), resource('wide-b', 'art')]; + const resources = [ + resource('wide-a', 'scene'), + resource('wide-b', 'scene'), + ]; const cardSizes = new Map([ ['wide-a', { width: 220, height: 180 }], ['wide-b', { width: 220, height: 180 }], @@ -726,9 +817,9 @@ describe('resource canvas variable card geometry', () => { it('uses actual dimensions for dependency columns, rows, and section extent', () => { const resources = [ - resource('source-a', 'art', 0), - resource('source-b', 'art', 0), - resource('target', 'art', 1), + resource('source-a', 'scene', 0), + resource('source-b', 'scene', 0), + resource('target', 'scene', 1), ]; const cardSizes = new Map([ ['source-a', { width: 220, height: 180 }], @@ -763,14 +854,14 @@ describe('resource canvas variable card geometry', () => { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'negative-card', - section: 'art', + section: 'scene', x: -240, y: -160, manuallyPlaced: true, }, { resourceId: 'positive-card', - section: 'art', + section: 'scene', x: 480, y: 320, manuallyPlaced: true, @@ -796,14 +887,14 @@ describe('resource canvas variable card geometry', () => { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'negative-card', - section: 'art', + section: 'scene', x: -240, y: -160, manuallyPlaced: true, }, { resourceId: 'positive-card', - section: 'art', + section: 'scene', x: 480, y: 320, manuallyPlaced: true, @@ -832,7 +923,15 @@ describe('resource canvas variable card geometry', () => { expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8); }); - it.each(['document', 'art', 'audio', 'code', 'version'])( + it.each([ + 'ui-interaction', + 'character', + 'scene', + 'audio', + 'document', + 'unclassified', + 'version', + ])( 'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available', () => { const viewport = fitResourceCanvasViewportToContent({ @@ -852,11 +951,11 @@ describe('resource canvas variable card geometry', () => { it('accepts card sizes directly on resource items for hook integration', () => { const wide = { - ...resource('wide', 'art', 0), + ...resource('wide', 'scene', 0), cardSize: { width: 220, height: 180 }, }; const target = { - ...resource('target', 'art', 1), + ...resource('target', 'scene', 1), cardSize: { width: 180, height: 128 }, }; const layout = reconcileResourceCanvasLayout( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index b22925c00..56350d935 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -12,7 +12,7 @@ function createResource( ): ProjectResource { return { id: 'asset:hero', - category: 'art', + category: 'ui-interaction', subtype: 'icon', label: '主角立绘', path: 'assets/hero.png', diff --git a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts index b00a1e89b..8eddc344c 100644 --- a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts @@ -38,7 +38,7 @@ describe('全类型资源编辑能力模型', () => { { resource: resource({ id: 'asset:png', - category: 'art', + category: 'unclassified', path: 'assets/hero.png', mediaType: 'image/png', manifestAssetId: 'png', @@ -49,7 +49,7 @@ describe('全类型资源编辑能力模型', () => { { resource: resource({ id: 'task:art:hero.webp', - category: 'art', + category: 'unclassified', path: 'hero.webp', mediaType: '美术产物', }), @@ -59,7 +59,7 @@ describe('全类型资源编辑能力模型', () => { { resource: resource({ id: 'asset:gif', - category: 'art', + category: 'unclassified', path: 'assets/hero.gif', mediaType: 'image/gif', manifestAssetId: 'gif', @@ -70,7 +70,7 @@ describe('全类型资源编辑能力模型', () => { { resource: resource({ id: 'asset:svg', - category: 'art', + category: 'unclassified', path: 'assets/icon.svg', mediaType: 'image/svg+xml', manifestAssetId: 'svg', @@ -81,7 +81,7 @@ describe('全类型资源编辑能力模型', () => { { resource: resource({ id: 'task:art:intro.mp4', - category: 'art', + category: 'unclassified', path: 'assets/intro.mp4', mediaType: '美术产物', }), @@ -171,7 +171,7 @@ describe('全类型资源编辑能力模型', () => { canonicalProjectedResourceMediaType( resource({ id: 'task:art:clip.mov', - category: 'art', + category: 'unclassified', path: 'assets/clip.mov', mediaType: '美术产物', }), @@ -211,7 +211,7 @@ describe('全类型资源编辑能力模型', () => { const capability = resolveProjectCharacterAnimationCapability( resource({ id: 'asset:hero', - category: 'art', + category: 'unclassified', path: 'assets/hero.png', mediaType: 'image/png', manifestAssetId: 'hero', diff --git a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx index ecf4f5ea0..280066dd4 100644 --- a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx @@ -81,21 +81,21 @@ function createManifestWithHero(localPath: string): GameCreationAppManifest { async function openHeroCard(name = 'hero.png') { fireEvent.click(screen.getByRole('button', { name: '按类型' })); const outline = await screen.findByLabelText('资源栏目大纲'); - fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); - // 资源画本先切到子画布再渲染卡片,等它落到 art 栏目再找卡。 + fireEvent.click(within(outline).getByRole('button', { name: /角色与对象/ })); + // 资源画本先切到子画布再渲染卡片,等它落到 character 栏目再找卡。 await waitFor(() => { const manager = document.querySelector('[data-resource-book-view="child"]'); expect(manager).not.toBeNull(); expect( manager?.querySelector( - '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="art"]', + '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]', ), ).not.toBeNull(); }); // C3 后点卡是「选中 + 浮出工具条」,重命名入口在工具条上。 fireEvent.click( await screen.findByRole('button', { - name: new RegExp(`^选中资源:美术资源 ${name}$`), + name: new RegExp(`^选中资源:角色与对象 ${name}$`), }), ); return screen.findByRole('toolbar', { name: '图片工具栏' }); @@ -205,7 +205,7 @@ describe('素材重命名前端链路', () => { await waitFor(() => { expect( screen.getByRole('button', { - name: '选中资源:美术资源 hero-v2.png', + name: '选中资源:角色与对象 hero-v2.png', }), ).not.toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx index eb7a98c86..ec60f2b9d 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -60,6 +60,7 @@ function createVersionedManifest(): GameCreationAppManifest { ? { ...task, status: 'completed' as const } : task, ); + // 两件资产必须落在同一栏目才能同屏比较版本高亮,因此都用 `character` 分类。 manifest.assets = [ { id: 'asset-player', @@ -70,7 +71,7 @@ function createVersionedManifest(): GameCreationAppManifest { }, { id: 'asset-town', - kind: 'scene', + kind: 'character', mediaType: 'image/png', localPath: 'assets/town.png', source: { kind: 'generated' }, @@ -148,7 +149,7 @@ function renderWorkbench( async function openArtCategory() { fireEvent.click(screen.getByRole('button', { name: '按类型' })); const outline = await screen.findByLabelText('资源栏目大纲'); - fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + fireEvent.click(within(outline).getByRole('button', { name: /角色与对象/ })); await waitFor(() => { expect( document.querySelector('[data-resource-book-view="child"]'), @@ -158,7 +159,7 @@ async function openArtCategory() { function cardFor(label: string) { return screen - .getByRole('button', { name: `选中资源:美术资源 ${label}` }) + .getByRole('button', { name: `选中资源:角色与对象 ${label}` }) .closest('.game-resource-card'); } diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index a18d1e583..b02b24196 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -27,7 +27,7 @@ function resource( ): ProjectResource { return { id, - category: 'art', + category: 'unclassified', subtype: 'image', label: id, path: `assets/${id}.png`, @@ -163,7 +163,10 @@ describe('useProjectResourceCardPreviews', () => { ); expect(invoke).toHaveBeenCalledWith( 'read_local_project_media_preview', - expect.objectContaining({ relativePath: art.path, category: 'art' }), + expect.objectContaining({ + relativePath: art.path, + category: 'unclassified', + }), ); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f56c99c09..6452e5b29 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,7 +18,7 @@ ## 2026-09-10 AGC 资源画布分区改为 6 类资产分类加项目版本栏目,旧栏目坐标读时归并 - 背景:资源画布原分区轴是「扩展名 + mediaType」派生的 `document / art / audio / code / version`,普通画布只显示其中四栏并隐藏游戏代码,与 `@` 面板、资源详情筛选已经收敛的单一权威 `category`(`ui-interaction / character / scene / audio / document / unclassified`)不一致。只登记游戏代码的项目会因资源签名非空进入分页画布,但 code 不在可见栏目里,于是四栏全空、一张卡都不显示。旧布局 sidecar 已存有旧 `section` 值,而 PRD 要求历史手动坐标只读恢复,不删除、不重置、不迁移。 -- 决策:分区轴改为 `PROJECT_RESOURCE_CANVAS_SECTIONS` = 6 类资产功能分类 + 末尾独立的「项目版本」栏目;项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。扩展名分类器降级为只负责准入与卡片显示类型(`.zip` / `.exe` 等无法识别的二进制产物与附件仍不进入画布),不再决定分区。`game-creator-resource-layout.v1` 不升版;Rust `ProjectResourceCanvasSection` 保留旧 `code` / `art` 白名单值继续可反序列化,且不加未知值兜底,损坏 payload 仍失败关闭。读取时按资源当前分区归并:`document / audio / version` 同名 1:1 保留;旧 `art` 按资源当前分类落入 `ui-interaction / character / scene / unclassified`;旧 `code` 落入 `unclassified`;无法精确归并时回落 `unclassified`。归并只改写 `section`,`x / y / manuallyPlaced` 原样保留,不写迁移脚本;旧 `art` / `code` / `version` 平面合并后可能出现同坐标叠卡,按既有「不重置坐标」口径原样保留,不改写自动坐标。 +- 决策:分区轴改为 `PROJECT_RESOURCE_CANVAS_SECTIONS` = 6 类资产功能分类 + 末尾独立的「项目版本」栏目;项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。扩展名分类器降级为只负责准入与卡片显示类型(`.zip` / `.exe` 等无法识别的二进制产物与附件仍不进入画布),不再决定分区。`game-creator-resource-layout.v1` 不升版;Rust `ProjectResourceCanvasSection` 保留旧 `code` / `art` 白名单值继续可反序列化,且不加未知值兜底,损坏 payload 仍失败关闭。读取时按资源当前分区归并:`document / audio / version` 同名 1:1 保留;旧 `art` 按资源当前分类落入 `ui-interaction / character / scene / unclassified`;旧 `code` 落入 `unclassified`;无法精确归并时回落 `unclassified`。归并只改写 `section`,`x / y / manuallyPlaced` 原样保留,不写迁移脚本;旧 `art` / `code` 平面并入同一新栏目后可能出现同坐标叠卡,按既有「不重置坐标」口径原样保留,不改写自动坐标,新资源仍由既有避让规则落位。 - 影响范围:`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`resourceProjectionModel.ts`、`resourceCanvasSectionMapping.ts`、`resourceCanvasLayoutModel.ts` 的栏目顺序与协调、`ProjectDevelopmentView` 的栏目标签 / 图标 / 可见栏目表。 - 验证方式:`resourceCanvasSectionMapping.test.ts` 覆盖旧值 × 目标栏目矩阵(坐标与 `manuallyPlaced` 原样保留、`section` 改写、独有旧值改写后必然判定为变化)与回落栏目属于目标集合;`projectResourceProjectionModel.test.ts` 覆盖 6 类分区投影、项目版本独立成栏和只登记游戏代码的项目落在「待归类」;Rust 侧覆盖旧 section JSON 反序列化、未知值拒绝与既有旧 sidecar 文件读取。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 165957875..d975d77f5 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -700,20 +700,13 @@ export function isLegacyProjectResourceCanvasSection( ); } -/** - * 旧栏目口径(扩展名 + mediaType)的分区值。当前仍被现役资源画布消费; - * 分区轴切换后由 `ProjectResourceCanvasCategory` 取代。 - */ -export type ProjectResourceCanvasSection = - | 'code' - | 'document' - | 'version' - | 'art' - | 'audio'; - export interface ProjectResourceCanvasPosition { resourceId: string; - section: ProjectResourceCanvasSection; + /** + * sidecar 里可能仍是旧栏目取值,读取时必须经 + * `normalizeResourceCanvasPosition` 归一到现行分区后再使用。 + */ + section: PersistedProjectResourceCanvasSection; x: number; y: number; manuallyPlaced: boolean;