资源工作台「替换素材」候选窗接上预览管线,补齐候选排序、禁用态可读性与删除素材入口
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Successful in 7m49s
Project CI / Native shell tests (pull_request) Successful in 18m27s

- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标
- 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路
- 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」
- 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径
- 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变)
- 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染
- 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除
- 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 ×
- 标签 pill 内的删除标签按钮保持不变
- 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
This commit is contained in:
2026-09-12 15:30:38 +08:00
parent 33a85a67b8
commit dc243b0d57
14 changed files with 1522 additions and 415 deletions
@@ -115,6 +115,51 @@ export function resourceReplacementAssetHints(
return hints;
}
/**
* 候选排序:**可替换(无硬门禁失败)的排在最前,其余按原名次跟随**。
*
* 判据与弹窗的禁用状态同源(`resourceReplacementBlockedReason`),所以"排在最前"与
* "点得动"永远是同一批条目 —— 不会出现排在最前却点不动的候选,也不会出现能点的候选被
* 挤在不可替换项后面。同档内显式按原名次排(不依赖 `Array.prototype.sort` 的稳定性),
* 保证任何引擎下结果一致。
*
* 不可替换项**不隐藏**:仍然渲染但禁用,把"为什么不能替换"留在界面上。
*/
export function sortResourceReplacementCandidates<
Candidate extends LocalProjectVersionReplacementCandidate,
>(candidates: readonly Candidate[]): Candidate[] {
return candidates
.map((candidate, index) => ({ candidate, index }))
.sort((left, right) => {
const leftBlocked =
resourceReplacementBlockedReason(left.candidate) !== null;
const rightBlocked =
resourceReplacementBlockedReason(right.candidate) !== null;
if (leftBlocked !== rightBlocked) return leftBlocked ? 1 : -1;
return left.index - right.index;
})
.map((entry) => entry.candidate);
}
/**
* manifest 资产 id → 资源投影(同一资产生成多个投影时取第一个,与资源画布口径一致)。
*
* 抽成纯函数是为了让「候选条目 ↔ 资源投影 ↔ 预览身份」这条链在弹窗素材映射与宿主渲染处
* 共用同一份口径,而不是各写一份 `forEach`。
*/
export function projectResourcesByManifestAssetId(
resources: readonly ProjectResource[],
): Map<string, ProjectResource> {
const resourceByAssetId = new Map<string, ProjectResource>();
for (const resource of resources) {
const assetId = resource.manifestAssetId;
if (assetId && !resourceByAssetId.has(assetId)) {
resourceByAssetId.set(assetId, resource);
}
}
return resourceByAssetId;
}
/**
* 候选 → 弹窗素材。
*
@@ -126,13 +171,7 @@ export function resourceReplacementPickerAssets(
resources: readonly ProjectResource[],
candidates: readonly LocalProjectVersionReplacementCandidate[],
): EditorAsset[] {
const resourceByAssetId = new Map<string, ProjectResource>();
for (const resource of resources) {
const assetId = resource.manifestAssetId;
if (assetId && !resourceByAssetId.has(assetId)) {
resourceByAssetId.set(assetId, resource);
}
}
const resourceByAssetId = projectResourcesByManifestAssetId(resources);
return candidates.flatMap((candidate) => {
const resource = resourceByAssetId.get(candidate.resourceId);
if (!resource) return [];
+16 -3
View File
@@ -6325,6 +6325,17 @@ iframe.preview-frame {
gap: 10px;
}
/*
* 「编辑素材标签」底部只有一个「添加」,靠右下角。
*
* 面板本身是 `display: grid`footer 作为网格项默认铺满整行;这里只把它改成靠右的行内排布,
* 按钮宽度仍由共享的 `PlatformActionButton` 决定。
*/
.game-resource-classification-footer {
display: flex;
justify-content: flex-end;
}
.game-resource-classification-error {
margin: 0;
color: #b3261e;
@@ -6413,10 +6424,11 @@ iframe.preview-frame {
}
/*
* 「替换素材」候选弹窗里的类型占位
* 「替换素材」候选弹窗里候选卡的媒体区
*
* AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`
* 所以候选行只渲染稳定类型图标,不挂 `<img>`、不出现破图。
* 缩略图由资源卡同一条预览管线(`useProjectResourceCardPreviews`)给出 Blob URL 后渲染
* 所以这里只负责"铺满弹窗给的 20 高度媒体槽 + 居中":读不到预览时它是类型占位的容器,
* 读到之后就是真实缩略图的容器,两种状态同一块版面、不跳尺寸。
*/
.game-resource-replacement-media {
display: flex;
@@ -6424,6 +6436,7 @@ iframe.preview-frame {
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
color: #8d7a6b;
}
@@ -13,28 +13,12 @@ import {
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
ResourceAssetDeleteDialog,
type ResourceAssetReferenceVersion,
} from './ResourceAssetDeleteDialog';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
type DeleteLocalProjectAssetResult = {
assetId: string;
localPath: string;
committedProjectRevision: number;
fileRetained: boolean;
};
type ReadLocalProjectAssetReferencesResult = {
assetId: string;
versions: ResourceAssetReferenceVersion[];
};
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
@@ -71,19 +55,12 @@ function resourceClassificationErrorMessage(error: unknown) {
return '保存素材标签失败';
}
function resourceDeleteErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '删除资源失败';
}
type ResourceClassificationPanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
};
export function ResourceClassificationPanel({
@@ -92,7 +69,6 @@ export function ResourceClassificationPanel({
asset,
onClose,
onSaved,
onDeleted,
}: ResourceClassificationPanelProps) {
/**
* 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。
@@ -111,17 +87,9 @@ export function ResourceClassificationPanel({
);
const [tagDraft, setTagDraft] = useState('');
const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false);
const [deleteReferencedVersions, setDeleteReferencedVersions] =
useState(false);
const [referencedVersions, setReferencedVersions] = useState<
readonly ResourceAssetReferenceVersion[]
>([]);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 回车 / 逗号 / 顿号都按同一口径切分;输入框仍是同一套提交方式。 */
/** 回车 / 逗号 / 顿号都按同一口径切分:把草稿落成 pill。 */
function commitTagDraft() {
if (!tagDraft.trim()) return;
setTags((current) =>
@@ -134,7 +102,12 @@ export function ResourceClassificationPanel({
setTags((current) => current.filter((item) => item !== tag));
}
async function saveResourceClassification() {
/**
* 保存标签:`tagsToSave` 由调用方给出(「添加」把输入框里还未落成 pill 的尾巴一起并入)。
*/
async function saveResourceClassification(
tagsToSave: readonly string[],
): Promise<void> {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('编辑素材标签需要在客户端内保存');
@@ -150,9 +123,6 @@ export function ResourceClassificationPanel({
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
// 输入框里还没按回车 / 逗号落成 pill 的尾巴也要一起保存,
// 沿用"整段文本在保存时统一切分"的既有行为,不让用户白输入。
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
const result =
await invoke<UpdateLocalProjectResourceClassificationResult>(
'update_local_project_resource_classification',
@@ -177,66 +147,16 @@ export function ResourceClassificationPanel({
}
/**
* 点删除先读引用信息,再打开独立确认面板。素材不可变:只摘掉 manifest 登记,磁盘文件保留
* 底部唯一的「添加」:把输入框里的内容(**含没按回车的尾巴**)落成 pill,然后按同一写入路径保存
*
* 落 pill 是即时反馈,保存是本次动作的语义本身 —— 拆成"先落 pill 再等用户去点保存"要求
* 用户理解两步,而这里只有一步。
*/
async function openDeleteResourceDialog() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('删除资源需要在客户端内执行');
return;
}
setDeleteDialogPreparing(true);
setError(null);
try {
const references = await invoke<ReadLocalProjectAssetReferencesResult>(
'read_local_project_asset_references',
{ input: { projectPath, assetId: asset.id } },
);
setReferencedVersions(references.versions);
setDeleteReferencedVersions(false);
setDeleteDialogOpen(true);
} catch (referenceError) {
setError(resourceDeleteErrorMessage(referenceError));
} finally {
setDeleteDialogPreparing(false);
}
}
async function confirmDeleteResource() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('删除资源需要在客户端内执行');
return;
}
setDeleting(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<DeleteLocalProjectAssetResult>(
'delete_local_project_asset',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
deleteReferencedVersions,
},
},
);
setDeleteDialogOpen(false);
onDeleted(result);
} catch (deleteError) {
setError(resourceDeleteErrorMessage(deleteError));
} finally {
setDeleting(false);
}
async function addTagDraftAndSave() {
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
setTags(tagsToSave);
setTagDraft('');
await saveResourceClassification(tagsToSave);
}
return (
@@ -304,41 +224,18 @@ export function ResourceClassificationPanel({
</p>
) : null}
</div>
<footer>
{/*
底部只留一个「添加」:它 = 落 pill + 保存。删除素材挪到资源卡选中工具条
(破坏性操作与它要改的对象放在一起),标签自身的删除按钮仍在每个 pill 内部。
*/}
<footer className="game-resource-classification-footer">
<PlatformActionButton
tone="danger"
onClick={() => void openDeleteResourceDialog()}
disabled={saving || deleting || deleteDialogPreparing}
aria-label="删除资源"
onClick={() => void addTagDraftAndSave()}
disabled={saving}
>
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
onClick={onClose}
disabled={saving || deleting}
>
</PlatformActionButton>
<PlatformActionButton
onClick={() => void saveResourceClassification()}
disabled={saving || deleting}
>
</PlatformActionButton>
</footer>
{deleteDialogOpen ? (
<ResourceAssetDeleteDialog
open
localPath={asset.localPath}
referencedVersions={referencedVersions}
deleteReferencedVersions={deleteReferencedVersions}
deleting={deleting}
onChangeDeleteReferencedVersions={setDeleteReferencedVersions}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={() => void confirmDeleteResource()}
/>
) : null}
</ThemedModal>
);
}
@@ -0,0 +1,132 @@
import { Image as ImageIcon, Music2, Video } from 'lucide-react';
import { useEffect, useRef } from 'react';
import {
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewState,
} from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
type ResourcePreviewMediaProps = {
/**
* 该条目对应的资源投影。`null` 表示宿主给不出投影(候选条目理论上都来自资源投影,
* 这里只做兜底):此时只渲染类型占位,不注册预览、不请求读取。
*/
resource: ProjectResource | null;
/**
* 资源卡预览身份(`useProjectResourceCardPreviews().identityByResourceId` 的取值)。
*
* 身份缺失时同样退化为类型占位:拿不到身份就无法进入预览队列,硬挂 `<img>` 只会是破图。
*/
previewIdentity: string | null;
/** 与资源卡同源的预览状态快照。 */
preview: ProjectResourceCardPreviewState;
/**
* 把本卡元素登记进资源卡预览管线(`useProjectResourceCardPreviews().observePreview`)。
*
* 登记即复核:`observePreview` 注册后立刻按几何判一次可见性,所以弹窗里的可见候选
* 开箱即进同一条队列;滚动等后续几何变化由宿主的兜底扫描补齐。
*/
onObservePreview: (
element: HTMLElement,
resource: ProjectResource,
identity: string,
) => () => void;
onPreviewDecodeError: (identity: string, error: string) => void;
/**
* 资源投影缺失时的类型占位口径(宿主条目自带的媒体类型,取值域是宿主的 `EditorAsset`)。
* 缺省按图片处理,与资源卡对未知类型的占位一致。
*/
fallbackMediaType?: string;
};
function resourcePreviewPlaceholderIcon(mediaType: string | undefined) {
if (mediaType === 'audio') {
return <Music2 className="h-5 w-5" aria-hidden="true" />;
}
if (mediaType === 'video') {
return <Video className="h-5 w-5" aria-hidden="true" />;
}
return <ImageIcon className="h-5 w-5" aria-hidden="true" />;
}
/**
* 弹窗类宿主(portal 里)的资源缩略图。
*
* 走的是**资源卡同一条预览管线**:身份取自 `identityByResourceId`、状态取自 `previews`、
* 可见性通过 `observePreview` 登记进同一张 `observedCards` 表。没有任何第二条读取通路:
* 这里只用管线给的 `sourceUrl` 渲染,读不到就退回类型占位(与资源卡的占位语义一致)。
*
* `data-resource-preview-status` 与资源卡一样暴露"为什么没有图"`idle` / `loading` /
* `failed` 在界面上都是占位图标,排障时需要能直接分辨。
*/
export function ResourcePreviewMedia({
resource,
previewIdentity,
preview,
onObservePreview,
onPreviewDecodeError,
fallbackMediaType,
}: ResourcePreviewMediaProps) {
const mediaRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = mediaRef.current;
if (!element || !resource || !previewIdentity) {
return undefined;
}
return onObservePreview(element, resource, previewIdentity);
}, [onObservePreview, previewIdentity, resource]);
const kind = resource ? projectResourceCardPreviewKind(resource) : null;
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
const visual = (() => {
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
return (
<img
src={sourceUrl}
alt=""
className="h-full w-full object-cover"
decoding="async"
onError={() => {
if (previewIdentity) {
onPreviewDecodeError(
previewIdentity,
'美术资源无法解码,请重新生成或替换该资源',
);
}
}}
/>
);
}
if (sourceUrl && kind === 'video') {
return (
<video
src={sourceUrl}
className="h-full w-full object-cover"
preload="metadata"
muted
playsInline
/>
);
}
return resourcePreviewPlaceholderIcon(
kind === 'video'
? 'video'
: kind === 'audio'
? 'audio'
: fallbackMediaType,
);
})();
return (
<span
ref={mediaRef}
className="game-resource-replacement-media"
data-resource-preview-status={preview.status}
>
{visual}
</span>
);
}
@@ -41,9 +41,9 @@ import {
Settings2,
SlidersHorizontal,
Sparkles,
Trash2,
Undo2,
Users,
Video,
X,
ZoomOut,
} from 'lucide-react';
@@ -148,10 +148,12 @@ import {
import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot';
import {
type LocalProjectVersionReplacementCandidate,
projectResourcesByManifestAssetId,
resourceReplacementAssetHints,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceVersionReplacementErrorMessage,
sortResourceReplacementCandidates,
} from '../../features/resource-canvas/resourceVersionReplacementModel';
import {
readVersionResourceReplacementCandidates,
@@ -171,6 +173,7 @@ import {
type ResourceFocusIntent,
uploadProjectAssetFilesAndReadSnapshot,
} from './projectResourceLiveUpdateModel';
import { ResourceAssetDeleteDialog } from './ResourceAssetDeleteDialog';
import {
createResourceBookTransitionController,
type ResourceBookTransitionController,
@@ -268,6 +271,7 @@ import {
ResourceInfoFieldsView,
ResourceInfoPanelView,
} from './ResourceInfoPanelView';
import { ResourcePreviewMedia } from './ResourcePreviewMedia';
import {
type ProjectAgentResultSummary,
type ProjectAttachmentResult,
@@ -292,6 +296,7 @@ import {
} from './useProjectResourceCanvasLayout';
import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews';
import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights';
import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow';
export type {
ProjectAgentResultSummary,
@@ -2809,6 +2814,21 @@ export default function ProjectDevelopmentView({
},
[reloadManifestAfterAssetCommand],
);
/**
*
*
* `ResourceAssetDeleteDialog`
* `delete_local_project_asset` `deleteReferencedVersions` hook
*
*/
const resourceAssetDeleteFlow = useResourceAssetDeleteFlow({
projectPath,
projectId: manifest.projectId,
onError: setResourceWorkbenchNotice,
onDeleted: (result) => {
void handleResourceClassificationDeleted(result);
},
});
/**
* manifest `localPath` id
* Rust `deny_unknown_fields`
@@ -4459,9 +4479,10 @@ export default function ProjectDevelopmentView({
) {
return;
}
const scale = (resourceBookOpensAllResources
? resourceBookAllViewportRef.current
: resourceCanvasViewportRef.current
const scale = (
resourceBookOpensAllResources
? resourceBookAllViewportRef.current
: resourceCanvasViewportRef.current
).scale;
if (!Number.isFinite(scale) || scale <= 0) {
return;
@@ -5405,10 +5426,31 @@ export default function ProjectDevelopmentView({
resourceReplacementSource,
],
);
/**
*
*
* `resourceReplacementCandidates` `sourceResourceId`
* id
*/
const sortedResourceReplacementCandidates = useMemo(
() => sortResourceReplacementCandidates(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
const resourceReplacementPickerEntries = useMemo(
() =>
resourceReplacementPickerAssets(resources, resourceReplacementCandidates),
[resourceReplacementCandidates, resources],
resourceReplacementPickerAssets(
resources,
sortedResourceReplacementCandidates,
),
[resources, sortedResourceReplacementCandidates],
);
/**
* 线
* `resourceReplacementPickerAssets`
*/
const resourceByManifestAssetId = useMemo(
() => projectResourcesByManifestAssetId(resources),
[resources],
);
const resourceReplacementBlockedReasonMap = useMemo(
() => resourceReplacementBlockedReasons(resourceReplacementCandidates),
@@ -5418,6 +5460,31 @@ export default function ProjectDevelopmentView({
() => resourceReplacementAssetHints(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
/**
* 线
*
* portal`document.body`** observer root **
* observer `observePreview`
* "打开弹窗"
* document
*/
const sweepResourceReplacementPreviews =
resourceCardPreviews.sweepVisiblePreviews;
useEffect(() => {
if (!resourceReplacementOpen) {
return undefined;
}
const sweep = () => {
sweepResourceReplacementPreviews();
};
const timer = setTimeout(sweep, 0);
// 滚动事件不冒泡,但捕获阶段的祖先监听器能收到;用它覆盖弹窗自己的滚动容器。
document.addEventListener('scroll', sweep, true);
return () => {
clearTimeout(timer);
document.removeEventListener('scroll', sweep, true);
};
}, [resourceReplacementOpen, sweepResourceReplacementPreviews]);
function showRunView() {
if (!runAvailable || uiEditorRoute) {
@@ -6557,6 +6624,47 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{/*
线
`image-canvas-editor__floating-toolbar-divider`
*/}
{selectedResource?.manifestAssetId ? (
<>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="删除素材"
title="删除素材"
icon={<Trash2 className="h-4 w-4" />}
disabled={
resourceAssetDeleteFlow.deleting ||
resourceAssetDeleteFlow.preparing
}
onClick={() => {
if (!selectedResource.manifestAssetId) {
return;
}
void resourceAssetDeleteFlow.requestDelete(
{
assetId:
selectedResource.manifestAssetId,
// 资源投影的 `path` 就是 manifest 资产的
// `localPath`(见 `resourceProjectionModel`),
// 与面板曾用的副标题同源。
localPath: selectedResource.path,
},
);
}}
>
<span></span>
</CanvasChromeButton>
</>
) : null}
</>
}
onOpenQuickEditPanel={openResourceQuickEditPanel}
@@ -7258,9 +7366,15 @@ export default function ProjectDevelopmentView({
asset={resourceClassificationAsset}
onClose={() => setResourceClassificationAssetId(null)}
onSaved={(result) => void handleResourceClassificationSaved(result)}
onDeleted={(result) =>
void handleResourceClassificationDeleted(result)
}
/>
) : null}
{/*
`ResourceAssetDeleteDialog` `useResourceAssetDeleteFlow`
*/}
{resourceAssetDeleteFlow.deleteDialogProps ? (
<ResourceAssetDeleteDialog
{...resourceAssetDeleteFlow.deleteDialogProps}
/>
) : null}
{resourceRenameAsset ? (
@@ -7279,8 +7393,8 @@ export default function ProjectDevelopmentView({
) : null}
{/*
+ + +
`renderAssetMedia` AGC scope Blob URL
`src` `<img>`
**线** / / LRU
线 `sourceUrl` 退
*/}
<ImageCanvasProjectAssetPickerDialog
open={resourceReplacementOpen}
@@ -7291,17 +7405,28 @@ export default function ProjectDevelopmentView({
assetBlockedReasons={resourceReplacementBlockedReasonMap}
assetHints={resourceReplacementHintMap}
errorMessage={resourceReplacementError}
renderAssetMedia={(asset) => (
<span className="game-resource-replacement-media" aria-hidden="true">
{asset.mediaType === 'audio' ? (
<Music2 className="h-5 w-5" />
) : asset.mediaType === 'video' ? (
<Video className="h-5 w-5" />
) : (
<Image className="h-5 w-5" />
)}
</span>
)}
renderAssetMedia={(asset) => {
const resource = resourceByManifestAssetId.get(asset.id) ?? null;
const previewIdentity = resource
? (resourceCardPreviews.identityByResourceId.get(resource.id) ??
null)
: null;
return (
<ResourcePreviewMedia
resource={resource}
previewIdentity={previewIdentity}
preview={
previewIdentity
? (resourceCardPreviews.previews.get(previewIdentity) ??
resourceCardPreviews.idlePreview)
: resourceCardPreviews.idlePreview
}
onObservePreview={resourceCardPreviews.observePreview}
onPreviewDecodeError={handleCardPreviewDecodeError}
fallbackMediaType={asset.mediaType}
/>
);
}}
onCancel={closeResourceVersionReplacement}
onConfirm={(assetIds) =>
void confirmResourceVersionReplacement(assetIds)
@@ -1291,6 +1291,17 @@ export function useProjectResourceCardPreviews(input: {
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
observePreview,
requestPreview,
/**
* 可见性兜底扫描(判据见 `viewportBandOfElement`:视口 ± 160px 内的 `idle` 卡补一次
* `visible`)。注册(`observePreview`)时本就会扫一次,这里把它暴露出来的唯一原因是
* **portal 宿主的几何变化事件 observer 收不到**
*
* 弹窗内容挂在 `document.body` 下,不在资源画本 observer 的 `root` 子树里 ⇒ observer
* 永远把它们报成"不相交";而扫描用的是**视口几何**,与 observer 的 root 无关,所以这类
* 宿主自己注册元素后,只要在「弹窗打开」「候选区滚动」这两个几何变化点上显式扫一次,
* 走的仍是同一条队列与同一条判据,不新增第二条加载通路。
*/
sweepVisiblePreviews,
failPreview,
protectPreview,
/**
@@ -0,0 +1,168 @@
import { useCallback, useState } from 'react';
import type { ResourceAssetReferenceVersion } from './ResourceAssetDeleteDialog';
/**
* `delete_local_project_asset` 的返回:素材不可变,删除只摘掉 manifest 登记,磁盘文件保留。
*/
export type DeleteLocalProjectAssetResult = {
assetId: string;
localPath: string;
committedProjectRevision: number;
fileRetained: boolean;
};
type ReadLocalProjectAssetReferencesResult = {
assetId: string;
versions: ResourceAssetReferenceVersion[];
};
/** 待删除的素材:`assetId` 是 manifest 资产 id`localPath` 只用于确认面板的副标题。 */
export type ResourceAssetDeleteTarget = {
assetId: string;
localPath: string;
};
type UseResourceAssetDeleteFlowInput = {
projectPath: string;
projectId: string;
/**
* 失败原因出口(读引用失败 / 删除失败 / 不在客户端内)。
*
* 宿主自己决定显示在哪里:面板内联成 `role="alert"`,资源卡工具条落到工作台提示条。
* 删除流程本身不持有错误态,避免出现"两处各显示半句失败原因"。
*/
onError: (message: string) => void;
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
};
export function resourceDeleteErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '删除资源失败';
}
/**
* 素材删除流程:读引用信息 → 二次确认 → 落盘删除。
*
* 「编辑素材标签」面板与资源卡选中工具条**共用这一份实现**(同一个原生命令、同一个二次确认
* 面板、同一套 `deleteReferencedVersions` 三分支),所以再没有第二份删除实现可以分叉。
*/
export function useResourceAssetDeleteFlow({
projectPath,
projectId,
onError,
onDeleted,
}: UseResourceAssetDeleteFlowInput) {
const [target, setTarget] = useState<ResourceAssetDeleteTarget | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [preparing, setPreparing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [referencedVersions, setReferencedVersions] = useState<
readonly ResourceAssetReferenceVersion[]
>([]);
const [deleteReferencedVersions, setDeleteReferencedVersions] =
useState(false);
/**
* 点删除先读引用信息,再打开独立确认面板;这一步不写盘。
*/
const requestDelete = useCallback(
async (next: ResourceAssetDeleteTarget) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
onError('删除资源需要在客户端内执行');
return;
}
setPreparing(true);
setTarget(next);
try {
const references = await invoke<ReadLocalProjectAssetReferencesResult>(
'read_local_project_asset_references',
{ input: { projectPath, assetId: next.assetId } },
);
setReferencedVersions(references.versions);
setDeleteReferencedVersions(false);
setDialogOpen(true);
} catch (referenceError) {
setTarget(null);
onError(resourceDeleteErrorMessage(referenceError));
} finally {
setPreparing(false);
}
},
[onError, projectPath],
);
const confirmDelete = useCallback(async () => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
onError('删除资源需要在客户端内执行');
return;
}
if (!target) return;
setDeleting(true);
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<DeleteLocalProjectAssetResult>(
'delete_local_project_asset',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: target.assetId,
deleteReferencedVersions,
},
},
);
setDialogOpen(false);
setTarget(null);
onDeleted(result);
} catch (deleteError) {
onError(resourceDeleteErrorMessage(deleteError));
} finally {
setDeleting(false);
}
}, [
deleteReferencedVersions,
onDeleted,
onError,
projectId,
projectPath,
target,
]);
const closeDeleteDialog = useCallback(() => {
setDialogOpen(false);
setTarget(null);
}, []);
return {
requestDelete,
/** 读引用信息在途:入口按钮据此禁用,避免连点发出两次确认面板。 */
preparing,
deleting,
/**
* 现有确认面板的现成入参。没有待删除目标时为 `null`,宿主直接不渲染 `ResourceAssetDeleteDialog`。
*/
deleteDialogProps: target
? {
open: dialogOpen,
localPath: target.localPath,
referencedVersions,
deleteReferencedVersions,
deleting,
onChangeDeleteReferencedVersions: setDeleteReferencedVersions,
onClose: closeDeleteDialog,
onConfirm: () => void confirmDelete(),
}
: null,
};
}
@@ -3719,8 +3719,9 @@ export function registerProjectWorkbenchFoundationTests() {
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源
// 面板同一条落盘链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板「删除素材」(破坏性动作放末位,
// 前置共享分隔线,复用素材删除流程)「下载按钮」复用资源面板同一条落盘链路,
// 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
name: '素材工具栏',
});
@@ -3731,7 +3732,14 @@ export function registerProjectWorkbenchFoundationTests() {
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['引用资源 bgm.mp3', '信息', '编辑标签', '重命名', '下载按钮']);
).toEqual([
'引用资源 bgm.mp3',
'信息',
'编辑标签',
'重命名',
'删除素材',
'下载按钮',
]);
// 工具条的「下载按钮」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
@@ -0,0 +1,328 @@
// @vitest-environment jsdom
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 { ResourceAssetDeleteDialog } from '../src/view/project-development/ResourceAssetDeleteDialog';
import type { DeleteLocalProjectAssetResult } from '../src/view/project-development/useResourceAssetDeleteFlow';
import { useResourceAssetDeleteFlow } from '../src/view/project-development/useResourceAssetDeleteFlow';
/**
* 素材删除流程的定向用例。
*
* 这条流程从「编辑素材标签」面板抽成了 `useResourceAssetDeleteFlow`,改由资源卡选中工具条
* 的「删除素材」触发;判据与实现在抽前后必须逐字不变 —— 所以这里钉住的是三件事:
* 1. 点入口只读引用、只开二次确认面板,不直接删;
* 2. `deleteReferencedVersions` 的三分支(不勾 / 勾 / 未被引用);
* 3. 失败时既不改状态也不静默:原因经宿主的 `onError` 出口出去。
*
* 工具条上的入口渲染判据(只有 `manifestAssetId` 存在才渲染)与真链路 IPC 载荷由
* `resourceVersionReplacement.test.tsx` 的工台用例覆盖,这里只测流程本身。
*/
const TARGET = { assetId: 'asset-hero', localPath: 'assets/hero.png' };
type InvokeMock = ReturnType<typeof vi.fn>;
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 DeleteFlowHarness({
onDeleted,
onError,
}: {
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
onError: (message: string) => void;
}) {
const flow = useResourceAssetDeleteFlow({
projectPath: 'C:/project',
projectId: 'project-1',
onError,
onDeleted,
});
return (
<div>
<button
type="button"
disabled={flow.preparing || flow.deleting}
onClick={() => void flow.requestDelete(TARGET)}
>
</button>
{flow.deleteDialogProps ? (
<ResourceAssetDeleteDialog {...flow.deleteDialogProps} />
) : null}
</div>
);
}
function renderDeleteFlow() {
const onDeleted = vi.fn();
const onError = vi.fn();
render(<DeleteFlowHarness onDeleted={onDeleted} onError={onError} />);
return { onDeleted, onError };
}
function deleteCalls(invoke: InvokeMock) {
return invoke.mock.calls.filter(
([command]) => command === 'delete_local_project_asset',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('useResourceAssetDeleteFlow 删除素材', () => {
test('入口只开确认面板不直接删;未被引用时不出现连带删除勾选', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(onDeleted).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: 'C:/project', assetId: 'asset-hero' },
});
expect(deleteCalls(invoke)).toHaveLength(0);
expect(
screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }),
).toBeNull();
// 面板副标题用的是落盘 `localPath`,不是素材 id。
expect(screen.getByText('assets/hero.png')).not.toBeNull();
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 9,
assetId: 'asset-hero',
deleteReferencedVersions: false,
},
});
expect(onDeleted.mock.calls[0]?.[0]).toEqual({
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
});
expect(onError).not.toHaveBeenCalled();
// 删完面板收起,不留一个"点不动"的确认面板。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull(),
);
});
test('列出引用该素材的版本,并默认不连带删除', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
{
versionId: 'agent-4',
projectRevision: 4,
createdAt: 1_760_003_600,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
});
renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', {
name: '确认删除资源',
});
expect(within(dialog).getByText('被 2 个游戏版本使用')).toBeTruthy();
expect(within(dialog).getByText('initial-1')).toBeTruthy();
expect(within(dialog).getByText('agent-4')).toBeTruthy();
expect(
(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}) as HTMLInputElement
).checked,
).toBe(false);
await user.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1));
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: false }),
}),
);
});
test('只有勾上连带删除才按 true 走同一分支', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
});
renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', {
name: '确认删除资源',
});
await user.click(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}),
);
await user.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1));
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
test('读引用失败时不弹空壳确认面板,原因经宿主出口说明白', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
throw '素材已被删除';
}
throw new Error(`unexpected command: ${command}`);
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('素材已被删除'));
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull();
expect(deleteCalls(invoke)).toHaveLength(0);
expect(onDeleted).not.toHaveBeenCalled();
});
test('删除失败时保留确认面板、透出原生拒绝,且不报删除成功', async () => {
const user = userEvent.setup();
installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw 'project-revision-conflict';
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('project-revision-conflict'),
);
expect(screen.getByRole('dialog', { name: '确认删除资源' })).not.toBeNull();
expect(onDeleted).not.toHaveBeenCalled();
});
test('不在客户端内时不发任何 IPC,直接说明原因', async () => {
const user = userEvent.setup();
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('删除资源需要在客户端内执行'),
);
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull();
expect(onDeleted).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -50,7 +51,6 @@ function renderPanel(
asset?: GameCreationAppAssetManifestEntry;
onClose?: () => void;
onSaved?: (result: unknown) => void;
onDeleted?: (result: unknown) => void;
} = {},
) {
render(
@@ -60,7 +60,6 @@ function renderPanel(
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
onDeleted={overrides.onDeleted ?? vi.fn()}
/>,
);
}
@@ -86,7 +85,7 @@ afterEach(() => {
});
describe('ResourceClassificationPanel 编辑素材标签', () => {
test('对齐用户截图:标题、素材名副标题、标签占位与底部按钮', () => {
test('对齐用户截图:标题、素材名副标题、标签占位与底部唯一的「添加」', () => {
installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
@@ -101,8 +100,13 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
expect(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
).not.toBeNull();
expect(screen.getByRole('button', { name: '保存标签' })).not.toBeNull();
expect(screen.getByRole('button', { name: '取消' })).not.toBeNull();
// 底部只留一个「添加」:保存 / 取消 / 删除三个旧按钮都撤了
// (关闭走头部 ×,删除素材在资源卡选中工具条上)。
expect(screen.getByRole('button', { name: '添加' })).not.toBeNull();
expect(screen.queryByRole('button', { name: '保存标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除资源' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
// 「管理全部标签」没有实现,分类也不再由这个面板编辑。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull();
@@ -136,7 +140,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
).not.toBeNull();
});
test('删除在保存前可撤销:点取消不写盘', async () => {
test('删掉 pill 之后只关面板不写盘:撤销不需要额外按钮', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const invoke = installInvoke(async () => undefined);
@@ -148,7 +152,8 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
await user.click(screen.getByRole('button', { name: '取消' }));
// 关闭只有头部 × 一个入口(底部不再有「取消」)。
await user.click(screen.getByRole('button', { name: '关闭编辑素材标签' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(classificationWrites(invoke)).toHaveLength(0);
@@ -174,7 +179,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
});
test('保存时把剩余标签写成数组,并原样回传读到的分类', async () => {
test('点「添加」把输入框与剩余标签写成数组,并原样回传读到的分类', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
const invoke = installInvoke(async (command) => {
@@ -196,7 +201,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'新春',
);
await user.click(screen.getByRole('button', { name: '保存标签' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
@@ -223,7 +228,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
*
* 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
*/
test('保存标签不改动落盘 category:自愈值不得被回写', async () => {
test('添加标签不改动落盘 category:自愈值不得被回写', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
@@ -247,7 +252,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
);
await user.click(screen.getByRole('button', { name: '保存标签' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -266,7 +271,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
});
});
test('归一化口径不变:重复与空白标签在保存前被收敛', async () => {
test('归一化口径不变:重复与空白标签在落盘前被收敛', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
@@ -278,7 +283,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, ' 主舞台 ,日夜,主舞台、');
await user.click(screen.getByRole('button', { name: '保存标签' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -290,6 +295,42 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
]);
});
/**
* 「添加」的语义包含**输入框里还没按回车的尾巴**:用户打了字直接点按钮,不能白输入。
*
* 这里刻意用 `fireEvent.click` 而不是 `user.click`:前者不会先让输入框失焦,所以只有
* 「点击处理器自己把草稿并进来」这一条路能让用例通过
* `onBlur` 的 `commitTagDraft` 兜不住它)。
*
* 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。
*/
test('点「添加」把没按回车的尾巴一起落成 pill 并保存', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
renderPanel({ asset: { ...asset, tags: ['主页'] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
fireEvent.change(field, { target: { value: '未回车的尾巴' } });
fireEvent.click(screen.getByRole('button', { name: '添加' }));
// 落 pill 是即时反馈,保存是这一步的语义本身。
await waitFor(() => {
expect(pillLabels()).toEqual(['主页', '未回车的尾巴']);
});
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主页',
'未回车的尾巴',
]);
});
test('surfaces the native rejection without reporting a save', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
@@ -302,7 +343,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '保存标签' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
@@ -314,7 +355,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
const onSaved = vi.fn();
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '保存标签' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
@@ -357,239 +398,57 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
});
});
describe('ResourceClassificationPanel 删除资源', () => {
test('deletes the asset registration only after the confirmation panel is submitted', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
describe('ResourceClassificationPanel 不再承载删除素材', () => {
/**
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 标签 pill 内的删除标签 → 底部「添加」,多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
*/
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
renderPanel();
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
// 删除入口只打开确认面板,不得直接发起删除。
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(onDeleted).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: 'C:/project', assetId: 'asset-hero' },
});
expect(
invoke.mock.calls.some(
([command]) => command === 'delete_local_project_asset',
),
).toBe(false);
// 未被任何版本引用时不出现连带删除勾选。
expect(
screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 9,
assetId: 'asset-hero',
deleteReferencedVersions: false,
},
});
expect(onDeleted.mock.calls[0]?.[0]).toEqual({
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
});
screen
.getAllByRole('button')
.map(
(button) => button.getAttribute('aria-label') ?? button.textContent,
),
).toEqual(['关闭编辑素材标签', '删除标签 主角', '添加']);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
expect(invoke).not.toHaveBeenCalled();
});
test('lists the versions using the asset and keeps the cascade option unchecked', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
{
versionId: 'agent-4',
projectRevision: 4,
createdAt: 1_760_003_600,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
/**
* 底部只有一个「添加」且靠右下角。
*
* 变异验证:把 `justify-content` 改回默认(或改 `flex-start`),本用例必须失败。
*/
test('底部只有一个「添加」并靠右下角', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const rule = styles.match(
/\.game-resource-classification-footer\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/display:\s*flex/);
expect(rule).toMatch(/justify-content:\s*flex-end/);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(screen.getByText('被 2 个游戏版本使用')).toBeTruthy();
expect(screen.getByText('initial-1')).toBeTruthy();
expect(screen.getByText('agent-4')).toBeTruthy();
installInvoke(async () => undefined);
renderPanel();
const cascade = screen.getByRole('checkbox', {
name: '把相关游戏版本一并删除',
});
expect((cascade as HTMLInputElement).checked).toBe(false);
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: false }),
}),
const footer = document.querySelector(
'.game-resource-classification-footer',
);
});
test('requests the cascade delete only when the user checks the option', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getAllByRole('button')).toHaveLength(
1,
);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(
screen.getByRole('checkbox', { name: '把相关游戏版本一并删除' }),
);
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
test('surfaces the native rejection without reporting a delete', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw 'project-revision-conflict';
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain(
'project-revision-conflict',
);
expect(onDeleted).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
import { afterEach } from 'vitest';
import {
act,
createGameCreationAppManifest,
@@ -115,7 +117,20 @@ function replacementManifest() {
const REPLACEMENT_CANDIDATES = {
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
// 后端给的名次里**不可替换项排在最前**(真实后端按资源名次给,不保证可替换项在前):
// 这样"候选排序"用例才有可失败的判据。
candidates: [
{
resourceId: 'asset-scene',
compatible: false,
compatibility: {
categoryEqual: false,
subtypeEqual: false,
sizeSpecEqual: true,
},
blockedReason: '分类不同',
warning: null,
},
{
resourceId: 'asset-final',
compatible: true,
@@ -139,17 +154,6 @@ const REPLACEMENT_CANDIDATES = {
blockedReason: null,
warning: '格式与源素材不同',
},
{
resourceId: 'asset-scene',
compatible: false,
compatibility: {
categoryEqual: false,
subtypeEqual: false,
sizeSpecEqual: true,
},
blockedReason: '分类不同',
warning: null,
},
],
};
@@ -173,12 +177,67 @@ const REPLACEMENT_RESULT = {
type RenderOptions = {
replacementCandidates?: () => Promise<unknown>;
replacementWrite?: () => Promise<unknown>;
/** 未被 manifest 登记的附件:用来验证「只有 manifest 资产才有删除素材入口」这一判据。 */
attachments?: Array<{
fileName: string;
mediaType: string;
localPath: string;
status: 'imported' | 'failed';
}>;
/** 引用这个素材的游戏版本;非空时确认面板要列出它们并给出连带删除勾选。 */
referencedVersions?: Array<{
versionId: string;
projectRevision: number;
createdAt: number;
}>;
deletion?: () => Promise<unknown>;
};
let observer: ReturnType<
typeof installResourceCardIntersectionObserver
> | null = null;
type RectOverride = { restore: () => void };
let rectOverride: RectOverride | null = null;
afterEach(() => {
rectOverride?.restore();
rectOverride = null;
});
/**
* 候选弹窗缩略图的可见性判据是**视口几何**`viewportBandOfElement`:视口 ± 160px),
* jsdom 里所有元素量出来都是 0×0 ⇒ 一律判"不在放行范围内",缩略图永远不会被请求。
*
* 这里只给候选弹窗自己的媒体元素一个真实矩形,其它元素保持原样:既不动画布布局计算,
* 也不改判据本身 —— 用例钉的还是线上那条几何放行路径。
*/
function installVisibleCandidateMediaRects() {
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function (this: Element) {
if (this.classList?.contains('game-resource-replacement-media')) {
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: 120,
bottom: 80,
width: 120,
height: 80,
toJSON: () => ({}),
} as DOMRect;
}
return original.call(this);
};
rectOverride = {
restore: () => {
Element.prototype.getBoundingClientRect = original;
},
};
}
function renderReplacementWorkbench(options: RenderOptions = {}) {
observer = installResourceCardIntersectionObserver();
const manifest = replacementManifest();
@@ -246,6 +305,25 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
? options.replacementWrite()
: REPLACEMENT_RESULT;
}
if (command === 'read_local_project_asset_references') {
const input = args?.input as { assetId?: string } | undefined;
return {
assetId: input?.assetId,
versions: options.referencedVersions ?? [],
};
}
if (command === 'delete_local_project_asset') {
if (options.deletion) {
return options.deletion();
}
const input = args?.input as { assetId?: string } | undefined;
return {
assetId: input?.assetId,
localPath: 'assets/legacy.png',
committedProjectRevision: 6,
fileRetained: true,
};
}
if (command === 'get_local_game_manifest') {
return nextManifest;
}
@@ -262,7 +340,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
attachments: options.attachments ?? [],
recentRunStatus: null,
recentRunStopReason: null,
activeVersionId: null,
@@ -432,6 +510,17 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 候选排序:可替换的排在最前,不可替换的(分类不同)按后端原名次跟随 ——
// 后端给的名次里它本来排在第一位。
expect(
within(dialog)
.getAllByRole('option')
.map((option) => option.getAttribute('aria-label')),
).toEqual([
'选择替换素材final.png',
'选择替换素材final.webp',
'选择替换素材scene.png',
]);
// 候选只列后端给出的素材:未绑定/未登记的素材不合成条目。
expect(
within(dialog).queryByRole('option', { name: '选择替换素材late.png' }),
@@ -545,4 +634,266 @@ describe('版本级资源替换', () => {
);
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
});
/**
* 候选弹窗的缩略图必须走**资源卡同一条预览管线**:候选媒体元素登记进同一张可见性登记表、
* 由同一条几何判据(视口 ± 160px)放行、读到的 Blob URL 直接渲染出来。
*
* 判据要点:弹窗内容挂在 portal 下,**不在资源画本 observer 的 root 子树里**,所以这里
* 不能指望 observer 回调 —— 走的是注册与几何扫描这条同源通路(用例只给候选媒体元素
* 一个真实矩形,不动判据本身)。
*
* 变异验证:把 `renderAssetMedia` 改回"只渲染类型占位图标",本用例必须失败
* (既不请求 `read_local_project_image_preview`,候选卡里也没有 `<img>`)。
*/
it('候选卡缩略图走资源卡同一条预览管线:可见候选读到图并渲染出来', async () => {
installVisibleCandidateMediaRects();
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_image_preview' &&
(args as { relativePath?: string } | undefined)?.relativePath ===
'assets/final.png',
),
).toBe(true);
});
const option = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
});
await waitFor(() => {
expect(option.querySelector('img')?.getAttribute('src')).toBe(
'blob:mock-attachment-preview',
);
});
// 预览状态与资源卡一样暴露在 DOM 上:占位图标到底是"还没读"还是"读失败",排障不用猜。
expect(
option
.querySelector('.game-resource-replacement-media')
?.getAttribute('data-resource-preview-status'),
).toBe('loaded');
});
/**
* portal 里的候选卡**不在资源画本 observer 的 root 子树里**observer 永远不会把它们报成
* 相交 —— 所以"可见才读"只能由几何兜底扫描承担,而扫描要在**几何变化点**被显式触发。
*
* 这里把候选媒体元素的矩形推迟到弹窗打开之后再给:打开时的注册扫描量不出尺寸、不请求,
* 只有滚动那一次显式扫描才放行。判据用的还是同一条几何口径(视口 ± 160px)。
*
* 变异验证:去掉弹窗打开时挂的滚动/定时兜底扫描,本用例必须失败。
*/
it('候选弹窗滚动时靠几何扫描补放行:portal 内容不在 observer 的 root 里', async () => {
const { invoke } = renderReplacementWorkbench();
const readsOf = (relativePath: string) =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'read_local_project_image_preview' &&
(args as { relativePath?: string } | undefined)?.relativePath ===
relativePath,
).length;
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 等打开时那一次扫描(含 0ms 兜底)跑完:量不出尺寸 ⇒ 一个候选都不请求。
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
// 场景候选的卡不在当前栏目里,所以这条路径的读取只可能来自弹窗。
expect(readsOf('assets/scene.png')).toBe(0);
installVisibleCandidateMediaRects();
fireEvent.scroll(document);
await waitFor(() => {
expect(readsOf('assets/scene.png')).toBeGreaterThan(0);
});
const sceneOption = within(dialog).getByRole('option', {
name: '选择替换素材scene.png',
});
await waitFor(() => {
expect(sceneOption.querySelector('img')?.getAttribute('src')).toBe(
'blob:mock-attachment-preview',
);
});
// 不可替换的候选也照样出图:禁用说的是"不能替换",不是"看不到素材"。
expect((sceneOption as HTMLButtonElement).disabled).toBe(true);
});
/**
* 工具条「删除素材」:破坏性动作放在末位 + 共享分隔线,复用同一套删除流程
* (读引用 → 二次确认 → `deleteReferencedVersions` 分支 → 重读 manifest)。
*
* 变异验证:
* - 把按钮挪到「替换素材」之前、或删掉前置分隔线,位置断言必须失败;
* - 把 `deleteReferencedVersions` 写死成 `false`,勾选用例(另一条分支)必须失败。
*/
it('工具条末位是「删除素材」:前置分隔线,且复用二次确认与 deleteReferencedVersions 分支', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench({
referencedVersions: [
{
versionId: SOURCE_VERSION_ID,
projectRevision: 1,
createdAt: 1_700_000_000,
},
],
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
const toolbarLabels = within(toolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label') ?? '');
// 末位:在最后一个非破坏性动作(替换素材)之后、共享下载按钮之前。
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('替换素材'),
);
expect(toolbarLabels.indexOf('删除素材')).toBeLessThan(
toolbarLabels.indexOf('下载按钮'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
const deleteButton = within(toolbar).getByRole('button', {
name: '删除素材',
});
const divider = deleteButton.previousElementSibling;
expect(divider?.getAttribute('aria-hidden')).toBe('true');
expect(divider?.getAttribute('class')).toContain(
'image-canvas-editor__floating-toolbar-divider',
);
// 点删除先读引用信息(不直接删),再开二次确认面板。
fireEvent.click(deleteButton);
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: PROJECT_PATH, assetId: 'asset-legacy' },
});
expect(
invoke.mock.calls.some(
([command]) => command === 'delete_local_project_asset',
),
).toBe(false);
// 被版本引用时列出引用版本;连带删除默认不勾(默认只摘登记,版本保留悬空绑定)。
expect(within(dialog).getByText('被 1 个游戏版本使用')).not.toBeNull();
expect(
(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}) as HTMLInputElement
).checked,
).toBe(false);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
assetId: 'asset-legacy',
deleteReferencedVersions: false,
},
});
});
// 删除成功后走与标签保存同一条 manifest 重载路径。
await waitFor(() =>
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
});
it('勾上连带删除时按 true 走同一分支', async () => {
const { invoke } = renderReplacementWorkbench({
referencedVersions: [
{
versionId: SOURCE_VERSION_ID,
projectRevision: 1,
createdAt: 1_700_000_000,
},
],
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
fireEvent.click(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
});
/**
* 入口判据与「编辑标签」同口径:只有 `manifestAssetId` 存在才渲染「删除素材」。
*
* 附件(`attachments`)没有 manifest 登记,删不了 —— 给它一个"点了报错"的按钮比不渲染更糟。
*
* 变异验证:把判据换成"只要选中就渲染",本用例必须失败。
*/
it('没有 manifest 身份的资源不渲染「删除素材」:与「编辑标签」同口径', async () => {
renderReplacementWorkbench({
attachments: [
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
});
fireEvent.click(await screen.findByRole('button', { name: '打开待归类' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull(),
);
act(() => {
observer?.triggerVisible();
});
fireEvent.click(await findResourceSelectButton('草稿.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
expect(
within(toolbar).queryByRole('button', { name: '删除素材' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '编辑标签' }),
).toBeNull();
// 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。
expect(
within(toolbar).getByRole('button', { name: '信息' }),
).not.toBeNull();
});
});
@@ -7,12 +7,14 @@ import type {
ProjectVersionResourceCompatibility,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
projectResourcesByManifestAssetId,
resourceReplacementAssetHints,
resourceReplacementBlockedReason,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceReplacementWarning,
resourceVersionReplacementErrorMessage,
sortResourceReplacementCandidates,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
readVersionResourceReplacementCandidates,
@@ -231,6 +233,127 @@ describe('资源替换的展示层口径', () => {
});
});
describe('候选择序与资源映射', () => {
/**
* 排序口径:**可替换(无硬门禁失败)的排最前,其余按后端原名次跟随**,不可替换项不隐藏。
*
* 变异验证:把 `sortResourceReplacementCandidates` 改成 identity(直接返回原数组),
* 本用例必须失败。
*/
it('可替换的排最前,不可替换的按原名次跟随,且不改写调用方数组', () => {
const candidates = [
candidate(
'asset-scene',
{ categoryEqual: false },
{ blockedReason: '分类不同' },
),
candidate('asset-ok'),
candidate(
'asset-subtype',
{ subtypeEqual: false },
{ blockedReason: '类型不同' },
),
candidate('asset-format', { sizeSpecEqual: false }),
// 后端说不可替换,但三项兼容性都是 true:兜底原因也是"不可选"。
candidate('asset-unknown', {}, { compatible: false }),
];
const sorted = sortResourceReplacementCandidates(candidates);
expect(sorted.map((entry) => entry.resourceId)).toEqual([
'asset-ok',
'asset-format',
'asset-scene',
'asset-subtype',
'asset-unknown',
]);
// 原名次不变:排序只改展示顺序,返回的是新数组。
expect(candidates.map((entry) => entry.resourceId)).toEqual([
'asset-scene',
'asset-ok',
'asset-subtype',
'asset-format',
'asset-unknown',
]);
expect(sorted).not.toBe(candidates);
});
/**
* 排序与弹窗的禁用状态必须同源:排在前面的候选**一定**没有禁用原因。
*
* 否则会出现"排在最前却点不动"的候选,排序就成了误导。
*
* 变异验证:把排序判据换成 `candidate.compatible === true`(而不是复用
* `resourceReplacementBlockedReason`),`asset-unknown` 会被排到前面,本用例必须失败。
*/
it('排在前面的候选一定没有禁用原因', () => {
const candidates = [
candidate('asset-ok'),
candidate('asset-unknown', {}, { compatible: false }),
candidate('asset-scene', { categoryEqual: false }),
candidate('asset-format', { sizeSpecEqual: false }),
];
const blockedReasons = resourceReplacementBlockedReasons(candidates);
const selectableCount = candidates.filter(
(entry) => blockedReasons[entry.resourceId] === undefined,
).length;
const sorted = sortResourceReplacementCandidates(candidates);
expect(selectableCount).toBe(2);
expect(
sorted
.slice(0, selectableCount)
.every((entry) => blockedReasons[entry.resourceId] === undefined),
).toBe(true);
expect(
sorted
.slice(selectableCount)
.every((entry) => blockedReasons[entry.resourceId] !== undefined),
).toBe(true);
});
it('manifest 资产 → 资源投影映射:跳过没有 manifest 身份的投影,同一资产取第一个', () => {
const manifest = manifestWithAssets([
{
id: 'asset-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
},
{
id: 'asset-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
},
]);
const resources = projectResourcesFromReadModels(
manifest,
[
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
[],
);
const resourceByAssetId = projectResourcesByManifestAssetId(resources);
expect(Array.from(resourceByAssetId.keys()).sort()).toEqual([
'asset-audio',
'asset-image',
]);
expect(resourceByAssetId.get('asset-image')?.path).toBe('assets/hero.png');
// 同一份映射喂给弹窗素材与候选缩略图,所以它必须与附件这类非 manifest 投影无关。
expect(
resources.some((entry) => entry.id === 'attachment:uploads/draft.png'),
).toBe(true);
});
});
describe('资源替换的 IPC 边界', () => {
it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => {
const invoke = vi.fn(async () => ({ ok: true }));
@@ -186,4 +186,40 @@ describe('ImageCanvasProjectAssetPickerDialog', () => {
expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy();
});
/**
* 禁用候选的「变灰」不能压在禁用原因上。
*
* 「分类不同」这类小字是用户唯一能看到的解释,整卡 `opacity-60` 会让它几乎读不出来;
* 视觉上的禁用感由缩略图与名称承担(这里用类名契约钉住分工,jsdom 不渲染真实对比度)。
*
* 变异验证:把 `opacity-60` 挪回整卡 `<button>` 上,本用例必须失败。
*/
it('keeps the disabled reason at full contrast instead of dimming the whole card', () => {
render(
<ImageCanvasProjectAssetPickerDialog
open
assets={ASSETS}
selectedAssetIds={[]}
assetBlockedReasons={{ 'asset-town': '分类不同' }}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
const option = screen.getByRole('option', { name: '选择参考图小镇背景' });
const media = option.firstElementChild as HTMLElement;
const reason = screen.getByText('分类不同');
// 整卡不再压透明度,只保留不可点的光标语义。
expect(option.className).toContain('cursor-not-allowed');
expect(option.className).not.toMatch(/opacity-\d/u);
// 变灰只压在缩略图上。
expect(media.className).toMatch(/opacity-\d/u);
expect(reason.className).not.toMatch(/opacity-\d/u);
expect(reason.className).toContain('font-medium');
expect(reason.className).toContain('var(--platform-text-strong)');
// 禁用项仍然渲染(不隐藏),原因与可点性都在。
expect((option as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -245,13 +245,23 @@ export function ImageCanvasProjectAssetPickerDialog({
selected
? 'border-[var(--platform-accent)] bg-white shadow-sm'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
blockedReason !== null ? 'cursor-not-allowed opacity-60' : '',
// 禁用项的"变灰"只压在缩略图与名称上,**不压禁用原因**:
// 「分类不同」这类小字压在整卡 `opacity-60` 下几乎读不出来,而它恰恰是
// 用户唯一能看到的解释。视觉上的禁用感由缩略图与名称承担。
blockedReason !== null ? 'cursor-not-allowed' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => toggleAsset(asset.id)}
>
<span className="flex h-20 w-full items-center justify-center overflow-hidden bg-white/60">
<span
className={[
'flex h-20 w-full items-center justify-center overflow-hidden bg-white/60',
blockedReason !== null ? 'opacity-50' : '',
]
.filter(Boolean)
.join(' ')}
>
{renderAssetMedia ? (
renderAssetMedia(asset)
) : projectAssetPickerCategory(asset) === 'image' ? (
@@ -264,7 +274,14 @@ export function ImageCanvasProjectAssetPickerDialog({
assetIcon(asset)
)}
</span>
<span className="flex min-w-0 items-center gap-1 px-2 py-1.5">
<span
className={[
'flex min-w-0 items-center gap-1 px-2 py-1.5',
blockedReason !== null ? 'opacity-60' : '',
]
.filter(Boolean)
.join(' ')}
>
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--platform-text-strong)]">
{asset.label}
</span>
@@ -276,7 +293,7 @@ export function ImageCanvasProjectAssetPickerDialog({
) : null}
</span>
{blockedReason !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug font-medium text-[var(--platform-text-strong)]">
{blockedReason}
</span>
) : hint !== null ? (