把素材类型从「编辑素材标签」面板拆成独立入口
- 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving - 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份 - 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css - ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证 - index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标) - index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项 - index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目 - 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口 - resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识 - 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条 - 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件 - 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例 - appSurface 工具条清单补上「素材类型」
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 「设置素材类型」面板的弹窗骨架与信息浮层的类型入口。
|
||||
*
|
||||
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
|
||||
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
|
||||
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
|
||||
*
|
||||
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
|
||||
* 中间一行可压缩可滚动、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
|
||||
*/
|
||||
.game-resource-type-dialog {
|
||||
width: min(480px, 100%);
|
||||
max-height: min(720px, calc(100dvh - 40px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/*
|
||||
* 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提,
|
||||
* 否则内容高度会顶回轨道、`overflow-y` 永远不触发。
|
||||
*/
|
||||
.game-resource-type-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/*
|
||||
* 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。
|
||||
*/
|
||||
.game-resource-type-hint {
|
||||
margin: 0;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-resource-type-error {
|
||||
margin: 0;
|
||||
color: #b3261e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 第二入口:信息浮层「分类」行右侧的入口按钮。
|
||||
*
|
||||
* 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用,
|
||||
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
|
||||
*/
|
||||
.game-resource-info-field-action {
|
||||
align-self: start;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-info-field-action:hover,
|
||||
.game-resource-info-field-action:focus-visible {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
@@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = {
|
||||
|
||||
const EMPTY_TAGS_TEXT = '暂无标签';
|
||||
|
||||
/**
|
||||
* 「分类」字段的行标识。
|
||||
*
|
||||
* 画布上的信息浮层用这一行接出素材类型设置入口(运行页签的「信息展示」不带入口,
|
||||
* 同一份字段清单在两处渲染);把字面量放在这里,模型与入口两侧不会各写一个。
|
||||
*/
|
||||
export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类';
|
||||
|
||||
/**
|
||||
* 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。
|
||||
*/
|
||||
@@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows(
|
||||
{ label: '名称', value: resource.label },
|
||||
{ label: '路径', value: resource.path },
|
||||
{ label: '类型', value: resource.mediaType },
|
||||
{ label: '分类', value: resourceCategoryLabel(resource.category) },
|
||||
{
|
||||
label: RESOURCE_INFO_CATEGORY_FIELD_LABEL,
|
||||
value: resourceCategoryLabel(resource.category),
|
||||
},
|
||||
{
|
||||
label: '标签',
|
||||
value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT,
|
||||
|
||||
+13
-59
@@ -5,38 +5,22 @@ import { useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||
import {
|
||||
GAME_CREATION_APP_ASSET_CATEGORIES,
|
||||
type GameCreationAppAssetCategory,
|
||||
gameCreationAppAssetCategory,
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
gameCreationAppAssetPersistedCategory,
|
||||
gameCreationAppAssetTags,
|
||||
normalizeGameCreationAppAssetTags,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
|
||||
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
|
||||
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
|
||||
|
||||
type UpdateLocalProjectResourceClassificationResult = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
committedProjectRevision: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
|
||||
*
|
||||
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
|
||||
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
|
||||
*/
|
||||
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
|
||||
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
|
||||
id: category,
|
||||
label: resourceReferenceCategoryLabel(category),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
|
||||
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
|
||||
@@ -57,16 +41,6 @@ function mergeResourceClassificationTagDraft(
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材名取 `localPath` 的 basename:manifest 资产没有独立的显示名字段,
|
||||
* 与资源卡、`@` 面板的显示口径一致。
|
||||
*/
|
||||
function resourceAssetDisplayName(localPath: string) {
|
||||
const normalized = localPath.replaceAll('\\', '/');
|
||||
const segments = normalized.split('/');
|
||||
return segments[segments.length - 1] || localPath;
|
||||
}
|
||||
|
||||
function resourceClassificationErrorMessage(error: unknown) {
|
||||
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
|
||||
// 与重命名、删除共用同一份映射。
|
||||
@@ -89,26 +63,17 @@ export function ResourceClassificationPanel({
|
||||
onSaved,
|
||||
}: ResourceClassificationPanelProps) {
|
||||
/**
|
||||
* 素材类型(功能分类)从本面板设置,与标签同一次保存、同一条写入路径。
|
||||
* 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。
|
||||
*
|
||||
* **选择器读的是「显示口径」** `gameCreationAppAssetCategory`:它与资源画布栏目
|
||||
* (`projectResourceAssetCategory` / `projectResourceCanvasCategory`)同一份读数,
|
||||
* 所以用户看到的选中项恰好就是他看到的那一栏,不存在「面板说 A、卡片在 B 栏」。
|
||||
*
|
||||
* **写回不能用这个读数**:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` 能派生出
|
||||
* 明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
|
||||
* **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径
|
||||
* `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind`
|
||||
* 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
|
||||
* (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。
|
||||
*
|
||||
* 因此用 `categoryChoice` 表达「用户是否主动选过」:
|
||||
* - `null`(没碰过分类控件)→ 回传 `gameCreationAppAssetPersistedCategory` 的落盘原值;
|
||||
* - 用户选过 → 回传用户选的那个值。
|
||||
* 这条分叉是本次改动的核心不变量,两个方向都由
|
||||
* `tests/resourceClassificationPanel.test.tsx` 的对照用例钉住。
|
||||
* 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的:
|
||||
* 本面板没有类型控件,`category` 恒为落盘原值。
|
||||
* `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它。
|
||||
*/
|
||||
const [categoryChoice, setCategoryChoice] =
|
||||
useState<GameCreationAppAssetCategory | null>(null);
|
||||
const displayedCategory =
|
||||
categoryChoice ?? gameCreationAppAssetCategory(asset);
|
||||
const [tags, setTags] = useState<string[]>(() =>
|
||||
gameCreationAppAssetTags(asset),
|
||||
);
|
||||
@@ -159,9 +124,8 @@ export function ResourceClassificationPanel({
|
||||
expectedProjectId: projectId,
|
||||
expectedProjectRevision: status.revision,
|
||||
assetId: asset.id,
|
||||
// 用户没主动选类型就原样回传落盘值(不含读时自愈),选了就写用户选的那个。
|
||||
category:
|
||||
categoryChoice ?? gameCreationAppAssetPersistedCategory(asset),
|
||||
// 分类不由本面板编辑:恒回传落盘原值(不含读时自愈)。
|
||||
category: gameCreationAppAssetPersistedCategory(asset),
|
||||
tags: normalizeGameCreationAppAssetTags(tagsToSave),
|
||||
},
|
||||
},
|
||||
@@ -214,20 +178,10 @@ export function ResourceClassificationPanel({
|
||||
</header>
|
||||
<div className="game-resource-classification-body">
|
||||
{/*
|
||||
素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。
|
||||
点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项),
|
||||
与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op。
|
||||
本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中
|
||||
工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的
|
||||
「添加」,导致"改了类型没生效"。
|
||||
*/}
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
|
||||
activeId={displayedCategory}
|
||||
onChange={setCategoryChoice}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="compact"
|
||||
/>
|
||||
{tags.length > 0 ? (
|
||||
<ul className="game-resource-tag-list" aria-label="已有标签">
|
||||
{tags.map((tag) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Info, X } from 'lucide-react';
|
||||
import {
|
||||
resolveResourceInfoFieldRows,
|
||||
resolveResourceInfoPanelStyle,
|
||||
RESOURCE_INFO_CATEGORY_FIELD_LABEL,
|
||||
type ResourceInfoPanelAnchor,
|
||||
} from '../../features/resource-canvas/resourceCanvasInfoModel';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
@@ -10,11 +11,17 @@ import type { ProjectResource } from './resourceProjectionModel';
|
||||
/**
|
||||
* 只读资源信息字段。运行页签的「信息展示」与画布上的信息浮层共用这一份,
|
||||
* 字段清单只在 `resolveResourceInfoFieldRows` 里定义,两处不会各说一套。
|
||||
*
|
||||
* `onEditCategory` 是「分类」行的可选入口(只有画布浮层传):分类值本身仍然只读展示,
|
||||
* 入口按钮渲染在 `dd` **外面** —— 字段值的读取口径是 `dt` / `dd` 的文本,
|
||||
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
|
||||
*/
|
||||
export function ResourceInfoFieldsView({
|
||||
resource,
|
||||
onEditCategory,
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
onEditCategory?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<dl className="game-resource-info-fields">
|
||||
@@ -22,6 +29,18 @@ export function ResourceInfoFieldsView({
|
||||
<div key={row.label}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
{onEditCategory &&
|
||||
row.label === RESOURCE_INFO_CATEGORY_FIELD_LABEL ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-info-field-action"
|
||||
aria-label="设置素材类型"
|
||||
title="设置素材类型"
|
||||
onClick={onEditCategory}
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -30,6 +49,10 @@ export function ResourceInfoFieldsView({
|
||||
|
||||
export type ResourceInfoPanelViewProps = ResourceInfoPanelAnchor & {
|
||||
resource: ProjectResource;
|
||||
/**
|
||||
* 「分类」行的类型设置入口;不传就没有入口(资源不是 manifest 资产时宿主不传)。
|
||||
*/
|
||||
onEditCategory?: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -44,6 +67,7 @@ export function ResourceInfoPanelView({
|
||||
sourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
onEditCategory,
|
||||
onClose,
|
||||
}: ResourceInfoPanelViewProps) {
|
||||
const style = resolveResourceInfoPanelStyle({
|
||||
@@ -75,7 +99,10 @@ export function ResourceInfoPanelView({
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<ResourceInfoFieldsView resource={resource} />
|
||||
<ResourceInfoFieldsView
|
||||
resource={resource}
|
||||
onEditCategory={onEditCategory}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import '../../features/project-workspace/resourceTypePanel.css';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import {
|
||||
GAME_CREATION_APP_ASSET_CATEGORIES,
|
||||
type GameCreationAppAssetCategory,
|
||||
gameCreationAppAssetCategory,
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
gameCreationAppAssetTags,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
|
||||
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
|
||||
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
|
||||
|
||||
type UpdateLocalProjectResourceClassificationResult = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
committedProjectRevision: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
|
||||
*
|
||||
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
|
||||
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
|
||||
*/
|
||||
const RESOURCE_TYPE_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map(
|
||||
(category) => ({
|
||||
id: category,
|
||||
label: resourceReferenceCategoryLabel(category),
|
||||
}),
|
||||
);
|
||||
|
||||
function resourceTypeErrorMessage(error: unknown) {
|
||||
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
|
||||
// 与重命名、删除、标签共用同一份映射。
|
||||
return projectAssetCommandErrorMessage(error, '设置素材类型失败');
|
||||
}
|
||||
|
||||
type ResourceTypePanelProps = {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
onClose: () => void;
|
||||
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 「设置素材类型」面板:素材类型(功能分类)的独立入口,与「编辑素材标签」彻底分家。
|
||||
*
|
||||
* 拆开的理由是原设计的动作语义错位 —— 类型 chip 曾长在标签弹窗里,点它只改本地 state,
|
||||
* 而全弹窗唯一的保存入口是标签的「添加」。于是「改类型」必须借道一个语义上是"加标签"的
|
||||
* 按钮,只选类型就直接关窗(点遮罩 / Esc / ×)则改动静默丢失。
|
||||
*
|
||||
* 本面板把动作压成一步:**选中即落盘**,不再有也只不需要任何标签动作。
|
||||
*
|
||||
* 三个口径要点:
|
||||
* 1. **显示**用读显示口径 `gameCreationAppAssetCategory`:它与画布栏目、资源卡角标同一份
|
||||
* 读数,用户看到的选中项恰好就是他看到的那一栏。
|
||||
* 2. **写回**用用户当次点的那个值,且只写这一个字段;`tags` 逐字回传
|
||||
* `gameCreationAppAssetTags(asset)`(落盘原值),不使用任何读时自愈口径
|
||||
* —— 改类型不许顺手改标签,也不许把自愈出来的值写回去。
|
||||
* 3. **没碰过就不写**:面板本身不产生"打开即写"或"关闭时补写",没有用户动作就没有写入。
|
||||
* 另一半对照(用户主动选了就必须写)由 `tests/resourceTypePanel.test.tsx` 钉住。
|
||||
*/
|
||||
export function ResourceTypePanel({
|
||||
projectPath,
|
||||
projectId,
|
||||
asset,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: ResourceTypePanelProps) {
|
||||
/**
|
||||
* 保存在飞时先把用户点的那一项显出来(否则 await 期间面板像没反应)。
|
||||
* 写入失败就退回显示口径,不留一个"看起来成功"的选中态。
|
||||
*/
|
||||
const [pendingCategory, setPendingCategory] =
|
||||
useState<GameCreationAppAssetCategory | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset);
|
||||
|
||||
async function saveResourceType(
|
||||
category: GameCreationAppAssetCategory,
|
||||
): Promise<void> {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setError('设置素材类型需要在客户端内保存');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await invoke<{ revision: number }>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath },
|
||||
);
|
||||
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
|
||||
throw new Error('项目 revision 无效');
|
||||
}
|
||||
const result =
|
||||
await invoke<UpdateLocalProjectResourceClassificationResult>(
|
||||
'update_local_project_resource_classification',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: projectId,
|
||||
expectedProjectRevision: status.revision,
|
||||
assetId: asset.id,
|
||||
// 用户点的就是落盘值:不套用读时自愈,也不和"是否碰过控件"分叉。
|
||||
category,
|
||||
// 类型面板不改标签:逐字回传落盘原值。
|
||||
tags: gameCreationAppAssetTags(asset),
|
||||
},
|
||||
},
|
||||
);
|
||||
onSaved(result);
|
||||
} catch (saveError) {
|
||||
setPendingCategory(null);
|
||||
setError(resourceTypeErrorMessage(saveError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel="设置素材类型"
|
||||
onClose={onClose}
|
||||
// 保存在飞时不许用 Escape / 点遮罩把面板关掉:关掉后迟到的 `onSaved`
|
||||
// 会打到一个已经卸载的面板上。头部 × 同样按 `saving` 禁用。
|
||||
closeOnBackdrop={!saving}
|
||||
closeOnEscape={!saving}
|
||||
panelClassName="game-approval-dialog game-resource-type-dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>设置素材类型</h2>
|
||||
<p>{resourceAssetDisplayName(asset.localPath)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭设置素材类型"
|
||||
disabled={saving}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-resource-type-body">
|
||||
{/* 只说这一屏要选什么,不写规则说明或开发解释。 */}
|
||||
<p className="game-resource-type-hint">选择这件素材所属的栏目</p>
|
||||
{/*
|
||||
选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项:
|
||||
用户显式确认归属,不做隐式 no-op)。
|
||||
*/}
|
||||
<div role="group" aria-label="素材类型">
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_TYPE_CATEGORY_OPTIONS}
|
||||
activeId={activeCategory}
|
||||
onChange={(category) => {
|
||||
setPendingCategory(category);
|
||||
void saveResourceType(category);
|
||||
}}
|
||||
columns="threeToSix"
|
||||
gap="sm"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="game-resource-type-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
Shapes,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
@@ -321,6 +322,7 @@ import {
|
||||
clampProjectResourceSectionZoom,
|
||||
projectResourceSectionZoomFromWheel,
|
||||
} from './resourceSectionHeightModel';
|
||||
import { ResourceTypePanel } from './ResourceTypePanel';
|
||||
import {
|
||||
describeProjectResourceCanvasLayoutRead,
|
||||
type ProjectResourceCanvasLayoutReadReport,
|
||||
@@ -1587,6 +1589,13 @@ export default function ProjectDevelopmentView({
|
||||
useState(false);
|
||||
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
|
||||
useState<string | null>(null);
|
||||
/**
|
||||
* 正在设置素材类型(功能分类)的素材;与 `resourceClassificationAssetId`(标签)分开持有:
|
||||
* 两块面板是两个独立入口,一块开着不该把另一块的宿主状态也算成开着。
|
||||
*/
|
||||
const [resourceTypeAssetId, setResourceTypeAssetId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */
|
||||
const [resourceRenameAssetId, setResourceRenameAssetId] = useState<
|
||||
string | null
|
||||
@@ -1824,6 +1833,14 @@ export default function ProjectDevelopmentView({
|
||||
const resourceCanvasHostGenerationPanelOpen =
|
||||
resourceGenerationOpen || resourceAssetGenerationAction !== null;
|
||||
|
||||
/**
|
||||
* 「编辑素材标签」与「设置素材类型」两块面板**共用一个宿主浮层判据**:
|
||||
* 它们都是 portal 到 body 的模态浮层,任何一块开着,点外部清焦点与画布自己的 Esc
|
||||
* 都必须让位。分开两个字段会让"只开其中一块"时漏掉一半判据。
|
||||
*/
|
||||
const resourceClassificationOverlayOpen =
|
||||
resourceClassificationAssetId !== null || resourceTypeAssetId !== null;
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: resolveResourceCanvasFloatingPanelDismissOpen({
|
||||
isCanvasVisible: mode === 'resources' && !uiEditorRoute,
|
||||
@@ -1831,7 +1848,7 @@ export default function ProjectDevelopmentView({
|
||||
hostOverlay: {
|
||||
isResourcePanelOpen: resourcePanelOpen,
|
||||
isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen,
|
||||
isClassificationPanelOpen: resourceClassificationAssetId !== null,
|
||||
isClassificationPanelOpen: resourceClassificationOverlayOpen,
|
||||
isRenameDialogOpen: resourceRenameAssetId !== null,
|
||||
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
|
||||
},
|
||||
@@ -1858,7 +1875,7 @@ export default function ProjectDevelopmentView({
|
||||
hostOverlay: {
|
||||
isResourcePanelOpen: resourcePanelOpen,
|
||||
isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen,
|
||||
isClassificationPanelOpen: resourceClassificationAssetId !== null,
|
||||
isClassificationPanelOpen: resourceClassificationOverlayOpen,
|
||||
isRenameDialogOpen: resourceRenameAssetId !== null,
|
||||
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
|
||||
},
|
||||
@@ -1878,7 +1895,8 @@ export default function ProjectDevelopmentView({
|
||||
isResourceCanvasFloatingPanelOpen,
|
||||
mode,
|
||||
resourceCanvasHostGenerationPanelOpen,
|
||||
resourceClassificationAssetId,
|
||||
// 判据用的是合并后的开关:两块分类面板任一开着都算宿主浮层打开。
|
||||
resourceClassificationOverlayOpen,
|
||||
resourcePanelOpen,
|
||||
resourceRecoveryPanelOpen,
|
||||
resourceRenameAssetId,
|
||||
@@ -2936,6 +2954,14 @@ export default function ProjectDevelopmentView({
|
||||
: null,
|
||||
[manifest.assets, resourceClassificationAssetId],
|
||||
);
|
||||
const resourceTypeAsset = useMemo(
|
||||
() =>
|
||||
resourceTypeAssetId
|
||||
? (manifest.assets.find((asset) => asset.id === resourceTypeAssetId) ??
|
||||
null)
|
||||
: null,
|
||||
[manifest.assets, resourceTypeAssetId],
|
||||
);
|
||||
const resourceRenameAsset = useMemo(
|
||||
() =>
|
||||
resourceRenameAssetId
|
||||
@@ -7263,6 +7289,21 @@ export default function ProjectDevelopmentView({
|
||||
<span>编辑标签</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="素材类型"
|
||||
title="素材类型"
|
||||
icon={<Shapes className="h-4 w-4" />}
|
||||
onClick={() =>
|
||||
setResourceTypeAssetId(
|
||||
selectedResource.manifestAssetId,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>素材类型</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
@@ -7461,6 +7502,23 @@ export default function ProjectDevelopmentView({
|
||||
sourceLayer={selectedResourceLayer}
|
||||
viewport={resourceCanvasSceneViewportRef.current}
|
||||
canvasSize={resourceBookSceneSize}
|
||||
/*
|
||||
第二入口:信息浮层的「分类」行点进「设置素材类型」。
|
||||
资源不是 manifest 资产时不给入口(按钮点了没反应就是假按钮)。
|
||||
打开类型面板前先收起信息浮层:它锚在卡片位置,而改类型后卡片会
|
||||
落到另一个栏目,留着它只会指向一个已经不在这儿的位置。
|
||||
*/
|
||||
onEditCategory={
|
||||
selectedResource.manifestAssetId
|
||||
? () => {
|
||||
const assetId =
|
||||
selectedResource.manifestAssetId;
|
||||
if (!assetId) return;
|
||||
setResourceInfoPanelOpen(false);
|
||||
setResourceTypeAssetId(assetId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setResourceInfoPanelOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -8073,6 +8131,28 @@ export default function ProjectDevelopmentView({
|
||||
onSaved={(result) => void handleResourceClassificationSaved(result)}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
「设置素材类型」:与标签面板并列的独立入口(素材类型 = 功能分类)。
|
||||
面板自己是"选中即落盘",宿主这里负责保存成功后收起面板 —— 用户必须马上在画布上
|
||||
看到卡片落进新栏目,而不是一块挡着画布的浮层。`reloadManifestAfterAssetCommand`
|
||||
的默认分支不认这个 state,所以关闭动作显式放在 `onSaved` 里,不靠隐式副作用。
|
||||
*/}
|
||||
{resourceTypeAsset ? (
|
||||
<ResourceTypePanel
|
||||
key={resourceTypeAsset.id}
|
||||
projectPath={projectPath}
|
||||
projectId={manifest.projectId}
|
||||
asset={resourceTypeAsset}
|
||||
onClose={() => setResourceTypeAssetId(null)}
|
||||
onSaved={(result) => {
|
||||
setResourceTypeAssetId(null);
|
||||
void reloadManifestAfterAssetCommand(
|
||||
result.committedProjectRevision,
|
||||
`asset-type:${result.asset.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
删除素材的二次确认面板:资源卡选中工具条删素材时用,与「编辑素材标签」面板曾用的
|
||||
是同一个 `ResourceAssetDeleteDialog` 与同一条删除流程(见 `useResourceAssetDeleteFlow`)。
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 素材名取 `localPath` 的 basename:manifest 资产没有独立的显示名字段,
|
||||
* 与资源卡、`@` 面板的显示口径一致。
|
||||
*
|
||||
* 「编辑素材标签」与「设置素材类型」两块面板共用这一份 —— 副标题是同一个素材名,
|
||||
* 两处各写一份 basename 实现迟早会出现一个带目录、一个不带。
|
||||
*/
|
||||
export function resourceAssetDisplayName(localPath: string) {
|
||||
const normalized = localPath.replaceAll('\\', '/');
|
||||
const segments = normalized.split('/');
|
||||
return segments[segments.length - 1] || localPath;
|
||||
}
|
||||
@@ -4017,9 +4017,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
|
||||
// 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
|
||||
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
|
||||
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板「删除素材」(破坏性动作放末位,
|
||||
// 前置共享分隔线,复用素材删除流程)「下载按钮」复用资源面板同一条落盘链路,
|
||||
// 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
|
||||
// `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
|
||||
// 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
|
||||
// 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
|
||||
// 不能再渲染成点了没反应的按钮。
|
||||
const audioToolbar = screen.getByRole('toolbar', {
|
||||
name: '素材工具栏',
|
||||
});
|
||||
@@ -4034,6 +4035,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'引用资源 bgm.mp3',
|
||||
'信息',
|
||||
'编辑标签',
|
||||
'素材类型',
|
||||
'重命名',
|
||||
'删除素材',
|
||||
'下载按钮',
|
||||
|
||||
@@ -1954,9 +1954,9 @@ describe('project resource live canvas integration', () => {
|
||||
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '编辑素材标签',
|
||||
name: '设置素材类型',
|
||||
});
|
||||
|
||||
// 面板里的类型选择器显示的就是卡片当前所在栏目。
|
||||
@@ -1964,8 +1964,8 @@ describe('project resource live canvas integration', () => {
|
||||
name: '角色与对象',
|
||||
});
|
||||
expect(categoryTab.getAttribute('aria-pressed')).toBe('true');
|
||||
// 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
@@ -1982,12 +1982,10 @@ describe('project resource live canvas integration', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
|
||||
);
|
||||
// 保存成功后由宿主收起面板(关窗不是用户的第二个动作):用户马上就能在画布上
|
||||
// 看到卡片落进新栏目,而不是隔着一块挡画布的浮层去猜。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
|
||||
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
|
||||
);
|
||||
|
||||
// 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主,
|
||||
@@ -2015,6 +2013,152 @@ describe('project resource live canvas integration', () => {
|
||||
expect(queryResourceSelectButton('hero.png')).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* 两块分类浮层的关闭时机互不串台:类型面板与标签面板都是 portal 到 body 的模态浮层,
|
||||
* 开着时「点外部清画布焦点」与画布自己的 Esc 都必须让位 ——
|
||||
* 关掉的只能是浮层本身,画布选中与工具条都得留着。
|
||||
*
|
||||
* 变异验证(已实测):把 `resourceTypeAssetId` 从 `isClassificationPanelOpen` 判据里去掉
|
||||
* (只留标签面板那一半),Esc 那一步会被画布的 Esc 抢走:类型面板关不掉、工具条一起消失,
|
||||
* 本用例必须失败。
|
||||
*/
|
||||
it('类型面板与标签面板:Esc / 点外部都只关浮层,不动画布选中', async () => {
|
||||
const { invoke } = installTauri();
|
||||
render(<ClassificationWorkbench />);
|
||||
|
||||
await openResourceBookCategory('角色与对象');
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
|
||||
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
|
||||
await screen.findByRole('dialog', { name: '设置素材类型' });
|
||||
fireEvent.click(document.body);
|
||||
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
|
||||
|
||||
// Esc 只关类型面板,选中(工具条)留着 —— 用户接着还能做别的动作。
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
|
||||
|
||||
// 标签面板走同一份判据:Esc 关面板,不清选中。
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('toolbar', { name: '图片工具栏' })).getByRole(
|
||||
'button',
|
||||
{ name: '编辑标签' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: '编辑素材标签' });
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
|
||||
|
||||
// 收尾自检:没有写入发生 —— 这两个动作都不该改数据。
|
||||
expect(classificationWrites(invoke)).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产
|
||||
* 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category`
|
||||
* 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。
|
||||
*
|
||||
* 变异验证(已实测):把标签面板的载荷改回读显示口径
|
||||
* (`gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`,
|
||||
* 本用例必须失败。
|
||||
*/
|
||||
it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => {
|
||||
const { invoke } = installTauri();
|
||||
render(<ClassificationWorkbench />);
|
||||
|
||||
await openResourceBookCategory('UI 交互');
|
||||
fireEvent.click(await findResourceSelectButton('panel.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' });
|
||||
|
||||
fireEvent.change(
|
||||
within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||||
{ target: { value: '界面' } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
|
||||
|
||||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
|
||||
const persisted = canvasFixture.manifest?.assets.find(
|
||||
(entry) => entry.id === 'asset-ui',
|
||||
);
|
||||
// 落盘原值逐字不变(不是自愈出来的 ui-interaction),标签写进去了。
|
||||
expect(persisted?.category).toBe('unclassified');
|
||||
expect(persisted?.tags).toEqual(['界面']);
|
||||
});
|
||||
|
||||
/**
|
||||
* 第二入口:信息浮层的「分类」行点进「设置素材类型」。
|
||||
*
|
||||
* 信息浮层是只读事实卡,新增的可点字段只有「分类」——点它等同于点工具条的「素材类型」:
|
||||
* 同一块面板、同一条写入命令,不另开第二条写路径。
|
||||
*
|
||||
* 变异验证:浮层不传 `onEditCategory`(或按钮渲染进 `dd`),本用例必须失败。
|
||||
*/
|
||||
it('信息浮层的「分类」行可以进类型设置,落盘走同一条链路', async () => {
|
||||
const { invoke } = installTauri();
|
||||
render(<ClassificationWorkbench />);
|
||||
|
||||
await openResourceBookCategory('角色与对象');
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
|
||||
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
|
||||
|
||||
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
|
||||
expect(
|
||||
Array.from(infoPanel.querySelectorAll('dt')).map(
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toContain('分类');
|
||||
expect(
|
||||
Array.from(infoPanel.querySelectorAll('dd')).map(
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toContain('角色与对象');
|
||||
|
||||
fireEvent.click(
|
||||
within(infoPanel).getByRole('button', { name: '设置素材类型' }),
|
||||
);
|
||||
|
||||
// 信息浮层先收起(它锚在卡片位置,而卡片马上要换栏目),类型面板接着打开。
|
||||
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
|
||||
const dialog = await screen.findByRole('dialog', { name: '设置素材类型' });
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: '角色与对象' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '音频' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: 'live-canvas-project',
|
||||
expectedProjectRevision: expect.any(Number),
|
||||
assetId: 'asset-hero',
|
||||
category: 'audio',
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动
|
||||
* 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。
|
||||
|
||||
@@ -114,7 +114,8 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
// 「管理全部标签」没有实现。
|
||||
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
|
||||
// 素材类型(功能分类)重新有了用户入口:6 个合法分类各一项。
|
||||
// 素材类型(功能分类)不再长在这个弹窗里:6 个分类项一个都不该出现,
|
||||
// 它们是「设置素材类型」面板的内容(见 tests/resourceTypePanel.test.tsx)。
|
||||
for (const label of [
|
||||
'UI 交互',
|
||||
'角色与对象',
|
||||
@@ -123,7 +124,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
|
||||
'文档',
|
||||
'待归类',
|
||||
]) {
|
||||
expect(screen.getByRole('button', { name: label })).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -465,18 +466,18 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
|
||||
* 素材类型(功能分类)**不再是这个面板的编辑对象**:它拆到了独立的「设置素材类型」面板
|
||||
* (`tests/resourceTypePanel.test.tsx`)。这一组钉住拆分后留下来的那条不变量 ——
|
||||
*
|
||||
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
|
||||
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `category` 必须仍是**落盘原值**,
|
||||
* 不能等于选择器显示的读显示值。
|
||||
*
|
||||
* 任何一边被改成另一边的口径,都会有一条例用变红,见各用例上的「变异验证」。
|
||||
* - 用户**没碰过**类型(本面板里根本不存在类型控件)→ 载荷 `category` 必须是**落盘原值**,
|
||||
* 不能等于读显示口径自愈出来的值;
|
||||
* - 对照的另一半(用户**主动选过**类型 → 载荷是用户选的那个值)在新面板那一侧,
|
||||
* 两边各自都有一条例用,任何一边被改成另一边的口径都会变红。
|
||||
*/
|
||||
describe('ResourceClassificationPanel 变更素材类型', () => {
|
||||
describe('ResourceClassificationPanel 不再编辑素材类型', () => {
|
||||
/**
|
||||
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
|
||||
* 也正是"选择器显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
|
||||
* 也正是"显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
|
||||
*/
|
||||
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
|
||||
...asset,
|
||||
@@ -498,13 +499,12 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
|
||||
});
|
||||
}
|
||||
|
||||
function segmentedTabItem(label: string) {
|
||||
return screen
|
||||
.getByRole('button', { name: label })
|
||||
.closest('.platform-segmented-tabs');
|
||||
}
|
||||
|
||||
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
|
||||
/**
|
||||
* 拆分的验收判据之一:类型 chip 从这个弹窗里彻底消失(一个都不许剩)。
|
||||
*
|
||||
* 变异验证:把 chips 加回面板,本用例必须失败。
|
||||
*/
|
||||
test('面板里没有任何素材类型控件', () => {
|
||||
installInvoke(async () => undefined);
|
||||
renderPanel({ asset: selfHealingAsset });
|
||||
|
||||
@@ -516,85 +516,27 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
|
||||
'文档',
|
||||
'待归类',
|
||||
]) {
|
||||
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
|
||||
expect(segmentedTabItem(label)).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
expect(document.querySelector('.platform-segmented-tabs')).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`,
|
||||
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」。
|
||||
* 只改标签(本面板**没有**类型控件,用户不可能在这里改类型):载荷 `category` 必须仍是
|
||||
* 落盘原值 `unclassified`,即使读显示口径会把这个 `kind:"ui"` 资产自愈成 `ui-interaction`。
|
||||
*
|
||||
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
|
||||
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位。
|
||||
*/
|
||||
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
|
||||
installInvoke(async () => undefined);
|
||||
renderPanel({ asset: selfHealingAsset });
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'UI 交互' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '待归类' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
/**
|
||||
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
|
||||
*
|
||||
* 变异验证:把写入改回"永远回传落盘原值"
|
||||
* (`category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`,
|
||||
* 本用例必须失败。
|
||||
*/
|
||||
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installClassificationInvoke();
|
||||
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
|
||||
input: {
|
||||
projectPath: 'C:/project',
|
||||
expectedProjectId: 'project-1',
|
||||
expectedProjectRevision: 7,
|
||||
assetId: 'asset-ui',
|
||||
category: 'scene',
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`,
|
||||
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
|
||||
*
|
||||
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
|
||||
* 这是与新面板「选中即落盘 → 载荷是用户选的值」互为对照的关键用例:
|
||||
* 没有它,任何"顺手把显示值写回去"的实现都会绿。
|
||||
*
|
||||
* 变异验证:把写入改成"永远回传读显示口径"(`gameCreationAppAssetCategory(asset)`),
|
||||
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
|
||||
* 本用例必须失败。
|
||||
*/
|
||||
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
|
||||
test('只改标签时载荷 category 仍是落盘原值,不把读显示口径的自愈值写回去', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installClassificationInvoke();
|
||||
renderPanel({ asset: selfHealingAsset });
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'UI 交互' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||||
'界面',
|
||||
@@ -616,62 +558,13 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 显式选「待归类」写下去的就是 `unclassified`。
|
||||
*
|
||||
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺:读显示口径会把
|
||||
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
|
||||
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
|
||||
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
|
||||
/*
|
||||
* 「显式选待归类」「连续改两次类型」这两条原属于本面板的用例已随 chip 一起迁到
|
||||
* `tests/resourceTypePanel.test.tsx`(那里点一下就是一次落盘,只钉写入值,不给
|
||||
* "卡片会挪到待归类栏目"的承诺 —— 读显示口径会把 `kind` 已能明确分类的资产自愈回派生栏目,
|
||||
* `unclassified` 无法区分"没有明确分类"与"用户显式选了待归类",这是 `gameCreationApp.ts`
|
||||
* 记录的有意取舍)。
|
||||
*/
|
||||
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installClassificationInvoke();
|
||||
renderPanel({ asset: { ...asset, category: 'character' } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '待归类' }));
|
||||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
const input = (
|
||||
classificationWrites(invoke)[0]?.[1] as {
|
||||
input: { category: string };
|
||||
}
|
||||
).input;
|
||||
expect(input.category).toBe('unclassified');
|
||||
});
|
||||
|
||||
/**
|
||||
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
|
||||
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
|
||||
*
|
||||
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
|
||||
* `category` 会退回落盘值,本用例必须失败。
|
||||
*/
|
||||
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
|
||||
const invoke = installClassificationInvoke();
|
||||
const onClose = vi.fn();
|
||||
renderPanel({ asset: selfHealingAsset, onClose });
|
||||
|
||||
const add = () => screen.getByRole('button', { name: '添加' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
|
||||
fireEvent.click(add());
|
||||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '音频' }));
|
||||
fireEvent.click(add());
|
||||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
|
||||
|
||||
expect(
|
||||
classificationWrites(invoke).map(
|
||||
([, args]) => (args as { input: { category: string } }).input.category,
|
||||
),
|
||||
).toEqual(['character', 'audio']);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResourceClassificationPanel 不再承载删除素材', () => {
|
||||
@@ -679,9 +572,9 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
|
||||
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
|
||||
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
|
||||
*
|
||||
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」,
|
||||
* 多出任何一个按钮都会红。
|
||||
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
|
||||
* 顺序也一并钉住:头部关闭 → 标签 pill 内的删除标签 → 底部「添加」。
|
||||
* 类型 chip 已拆到「设置素材类型」面板,这里多出任何一个按钮都会红。
|
||||
* 变异验证:把 footer 的删除素材按钮(或类型 chip)加回来,本用例必须失败。
|
||||
*/
|
||||
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
|
||||
const invoke = installInvoke(async (command) => {
|
||||
@@ -695,18 +588,7 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
|
||||
.map(
|
||||
(button) => button.getAttribute('aria-label') ?? button.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
'关闭编辑素材标签',
|
||||
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
|
||||
'UI 交互',
|
||||
'角色与对象',
|
||||
'场景与环境',
|
||||
'音频',
|
||||
'文档',
|
||||
'待归类',
|
||||
'删除标签 主角',
|
||||
'添加',
|
||||
]);
|
||||
).toEqual(['关闭编辑素材标签', '删除标签 主角', '添加']);
|
||||
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ResourceTypePanel } from '../src/view/project-development/ResourceTypePanel';
|
||||
|
||||
const asset: GameCreationAppAssetManifestEntry = {
|
||||
id: 'asset-hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'uploaded' },
|
||||
category: 'character',
|
||||
tags: ['主角'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
|
||||
* 也正是"选择器显示值 ≠ 落盘值"的现场(真机 57 条 `kind:"ui"` 资产就是这个形状)。
|
||||
*/
|
||||
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
|
||||
...asset,
|
||||
id: 'asset-ui',
|
||||
kind: 'ui',
|
||||
category: 'unclassified',
|
||||
tags: [],
|
||||
};
|
||||
|
||||
function installInvoke(
|
||||
implementation: (command: string, args?: unknown) => Promise<unknown>,
|
||||
) {
|
||||
const invoke = vi.fn(implementation);
|
||||
(
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: typeof invoke } };
|
||||
}
|
||||
).__TAURI__ = { core: { invoke } };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
function removeInvoke() {
|
||||
delete (
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: unknown } };
|
||||
}
|
||||
).__TAURI__;
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: {
|
||||
asset?: GameCreationAppAssetManifestEntry;
|
||||
onClose?: () => void;
|
||||
onSaved?: (result: unknown) => void;
|
||||
} = {},
|
||||
) {
|
||||
render(
|
||||
<ResourceTypePanel
|
||||
projectPath="C:/project"
|
||||
projectId="project-1"
|
||||
asset={overrides.asset ?? asset}
|
||||
onClose={overrides.onClose ?? vi.fn()}
|
||||
onSaved={overrides.onSaved ?? vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
|
||||
return invoke.mock.calls.filter(
|
||||
([command]) => command === 'update_local_project_resource_classification',
|
||||
);
|
||||
}
|
||||
|
||||
function typeTab(label: string) {
|
||||
return screen.getByRole('button', { name: label });
|
||||
}
|
||||
|
||||
function pressed(label: string) {
|
||||
return typeTab(label).getAttribute('aria-pressed');
|
||||
}
|
||||
|
||||
/** 一次写入的落地载荷(没有写入时返回 `null`)。 */
|
||||
function firstWriteInput(invoke: ReturnType<typeof vi.fn>) {
|
||||
const write = classificationWrites(invoke)[0];
|
||||
return write ? (write[1] as { input: Record<string, unknown> }).input : null;
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
removeInvoke();
|
||||
});
|
||||
|
||||
describe('ResourceTypePanel 设置素材类型', () => {
|
||||
/**
|
||||
* 面板的身份就是「类型」这一个关注点:标题、素材名副标题、6 个类型项,
|
||||
* 且**不含任何标签控件**。类型与标签拆成两个入口后,两边都不许再长出对方的控件 ——
|
||||
* 这正是用户报的「改类型必须借道标签的『添加』按钮」的根因。
|
||||
*/
|
||||
test('标题是「设置素材类型」,副标题是素材名,内容只有 6 个类型项', () => {
|
||||
installInvoke(async () => undefined);
|
||||
renderPanel({
|
||||
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '设置素材类型' }),
|
||||
).not.toBeNull();
|
||||
// 副标题与「编辑素材标签」同一显示口径:`localPath` 的 basename。
|
||||
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
|
||||
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
|
||||
|
||||
// 6 个合法分类,顺序就是画布栏目顺序,文案与栏目同源。
|
||||
const tabs = within(document.querySelector('.platform-segmented-tabs')!);
|
||||
expect(
|
||||
tabs.getAllByRole('button').map((button) => button.textContent),
|
||||
).toEqual([
|
||||
'UI 交互',
|
||||
'角色与对象',
|
||||
'场景与环境',
|
||||
'音频',
|
||||
'文档',
|
||||
'待归类',
|
||||
]);
|
||||
|
||||
// 标签不是这个面板的编辑对象:输入框与「添加」都不在这里。
|
||||
expect(
|
||||
screen.queryByPlaceholderText('新增标签,多个用逗号分隔'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '添加' })).toBeNull();
|
||||
expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* 选中项是**读显示口径**(与资源卡栏目、角标同一份读数):该资产落盘 `unclassified`,
|
||||
* 画布把它放在「UI 交互」栏,面板必须也显示「UI 交互」。
|
||||
*
|
||||
* 变异验证:把 `activeId` 换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
|
||||
* 选中项会变成「待归类」,本用例必须失败。
|
||||
*/
|
||||
test('选中项是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
|
||||
installInvoke(async () => undefined);
|
||||
renderPanel({ asset: selfHealingAsset });
|
||||
|
||||
expect(pressed('UI 交互')).toBe('true');
|
||||
expect(pressed('待归类')).toBe('false');
|
||||
});
|
||||
|
||||
/**
|
||||
* **本次改动的核心**:选中一项本身就是一次完整动作 —— 该次操作自己落盘,
|
||||
* 不需要任何标签动作(不收标签、也不点「添加」)。
|
||||
*
|
||||
* 变异验证:把载荷 `category` 改回 `gameCreationAppAssetPersistedCategory(asset)`
|
||||
* (即"永远回传落盘原值"),载荷会变成 `unclassified`,本用例必须失败。
|
||||
*/
|
||||
test('选中一项即落盘:载荷 category 是用户选的值,且不依赖任何标签动作', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
return { asset: selfHealingAsset, committedProjectRevision: 8 };
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderPanel({ asset: selfHealingAsset, onSaved, onClose });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
expect(firstWriteInput(invoke)).toEqual({
|
||||
projectPath: 'C:/project',
|
||||
expectedProjectId: 'project-1',
|
||||
expectedProjectRevision: 7,
|
||||
assetId: 'asset-ui',
|
||||
category: 'scene',
|
||||
tags: [],
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
// 关窗是宿主的职责(面板只报保存结果),与「编辑素材标签」面板同一分工。
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* 只改类型时 `tags` 逐字回传**落盘原值**:类型面板不是改标签的地方,
|
||||
* 写入载荷里的 tags 必须与 manifest 上的完全一致(不归一化、不去重、不排序)。
|
||||
*
|
||||
* 变异验证:把 `tags` 换成 `[]`(或任何重算口径),本用例必须失败。
|
||||
*/
|
||||
test('只改类型时 tags 逐字回传落盘原值', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
return { asset, committedProjectRevision: 8 };
|
||||
});
|
||||
renderPanel({ asset: { ...asset, tags: ['主角', '待定稿'] } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '音频' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
expect(firstWriteInput(invoke)?.tags).toEqual(['主角', '待定稿']);
|
||||
expect(firstWriteInput(invoke)?.category).toBe('audio');
|
||||
});
|
||||
|
||||
/**
|
||||
* 对照用例(从「编辑素材标签」面板迁移过来的同一条不变量,方向相反):
|
||||
* **用户没碰过类型控件**时,`category` 的落盘原值不许被写回 —— 面板打开、看一眼、关掉,
|
||||
* 一次写入都不该发生,更不该顺手把读时自愈出来的值写回落盘。
|
||||
*
|
||||
* 本面板是「选中即落盘」,所以"没碰过"= 没有写入;对照的另一半(用户主动选了就必须写)
|
||||
* 由上一条用例钉住。
|
||||
*
|
||||
* 变异验证:把面板改成"打开即按显示口径写一次"(或在关闭时补写一次),本用例必须失败。
|
||||
*/
|
||||
test('没碰过类型控件就不写盘:打开再关闭不产生任何分类写入', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
renderPanel({ asset: selfHealingAsset, onClose });
|
||||
|
||||
// 显示口径确实是 ui-interaction(没有被写成 unclassified 的现场)。
|
||||
expect(pressed('UI 交互')).toBe('true');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭设置素材类型' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(classificationWrites(invoke)).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 显式选「待归类」写下去的就是 `unclassified`,**不被 `kind` 派生值改写**:
|
||||
* 该资产 `kind:"ui"` 能派生出 `ui-interaction`,若载荷改走读显示口径,
|
||||
* 用户点「待归类」就会落成 `ui-interaction`。
|
||||
*
|
||||
* 变异验证:把载荷 `category` 换成 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
|
||||
*/
|
||||
test('显式选中「待归类」:载荷 category 是 unclassified,不被 kind 派生值覆盖', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
return { asset: selfHealingAsset, committedProjectRevision: 8 };
|
||||
});
|
||||
renderPanel({ asset: selfHealingAsset });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '待归类' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||||
});
|
||||
expect(firstWriteInput(invoke)?.category).toBe('unclassified');
|
||||
});
|
||||
|
||||
test('原生拒绝原样透出,且不报保存成功、选中态退回显示口径', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
throw '非法资源分类:future-category';
|
||||
});
|
||||
renderPanel({ asset: selfHealingAsset, onSaved });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
// 写入没成功:选中态不能被留在失败的选项上。
|
||||
expect(pressed('UI 交互')).toBe('true');
|
||||
expect(pressed('场景与环境')).toBe('false');
|
||||
});
|
||||
|
||||
test('客户端外不写盘,只给提示', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
renderPanel({ asset: selfHealingAsset, onSaved });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('alert').textContent).toContain('客户端');
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存在飞时面板锁住:选项与头部 × 都禁用,Esc / 点遮罩都不关窗。
|
||||
* 否则第二次点击会用旧 revision 并发写,或在 `onSaved` 迟到时打到已卸载的面板上。
|
||||
*
|
||||
* 点遮罩走 `user.click(遮罩)` 这条真实路径(pointerdown / pointerup / click 全落在遮罩上):
|
||||
* `ThemedModal` 用 pointer 序列区分「在面板内按下、拖到遮罩上松手」,只发一个 click
|
||||
* 是模拟不出"点遮罩"的,那样的断言恒绿、钉不住 `closeOnBackdrop`。
|
||||
*
|
||||
* 变异验证:把 `closeOnBackdrop / closeOnEscape` 改回恒 `true`,本用例必须失败。
|
||||
*/
|
||||
test('保存在飞时禁用选项与关闭入口,Esc / 点遮罩都关不掉', async () => {
|
||||
const user = userEvent.setup();
|
||||
const deferred = createDeferred<unknown>();
|
||||
installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
return deferred.promise;
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
renderPanel({ asset: selfHealingAsset, onClose, onSaved });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '设置素材类型' });
|
||||
await waitFor(() => {
|
||||
expect((typeTab('场景与环境') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '关闭设置素材类型',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
|
||||
await user.click(dialog.parentElement!);
|
||||
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve({ asset: selfHealingAsset, committedProjectRevision: 8 });
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('非保存在飞时:Esc 与点遮罩各关一次,点面板内部不关', async () => {
|
||||
const user = userEvent.setup();
|
||||
installInvoke(async () => undefined);
|
||||
const onClose = vi.fn();
|
||||
renderPanel({ onClose });
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '设置素材类型' });
|
||||
await user.click(screen.getByRole('heading', { name: '设置素材类型' }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(dialog.parentElement!);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 弹窗骨架(`max-height` + 可压缩可滚的中间行)不能只挂在「编辑素材标签」上:
|
||||
* 类型面板的 6 个选项在窄屏上是 3 行,缺了上界同样会顶出视口。
|
||||
*/
|
||||
test('面板有高度上界且中间行可滚动', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const dialogRule = styles.match(
|
||||
/\.game-resource-type-dialog\s*\{([^}]*)\}/s,
|
||||
)?.[1];
|
||||
expect(dialogRule).toBeDefined();
|
||||
expect(dialogRule).toMatch(
|
||||
/max-height:\s*min\(720px, calc\(100dvh - 40px\)\)/,
|
||||
);
|
||||
expect(dialogRule).toMatch(/grid-template-rows:\s*auto minmax\(0, 1fr\)/);
|
||||
|
||||
const bodyRule = styles.match(
|
||||
/\.game-resource-type-body\s*\{([^}]*)\}/s,
|
||||
)?.[1];
|
||||
expect(bodyRule).toBeDefined();
|
||||
expect(bodyRule).toMatch(/min-height:\s*0/);
|
||||
expect(bodyRule).toMatch(/overflow-y:\s*auto/);
|
||||
|
||||
installInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
const panel = document.querySelector('.game-resource-type-dialog');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(
|
||||
Array.from(panel!.children).map((child) =>
|
||||
child.classList.contains('game-resource-type-body')
|
||||
? 'body'
|
||||
: child.tagName.toLowerCase(),
|
||||
),
|
||||
).toEqual(['header', 'body']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user