资源工作台「替换素材」候选窗接上预览管线,补齐候选排序、禁用态可读性与删除素材入口
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 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:
+46
-7
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+24
-127
@@ -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)
|
||||
|
||||
+11
@@ -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,
|
||||
/**
|
||||
|
||||
+168
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user