画布资源卡统一显示正式资源名并简化文档卡卡面
- 资源卡新增卡面名称条,所有类型卡统一显示正式资源 label,长名单行省略、完整名进 title - 文档卡卡面改为居中图标,不再把正文前几行铺在卡面上,正文只在独立详情浮层读取 - 删除卡面正文摘要的取文本 helper 与行数/长度常量,移除对应 CSS 规则不留墓碑 - 名称条按媒体卡让出右下播放钮位置,替换血缘角标上移避免遮住名称 - 新增资源卡名称与文档卡卡面用例,并同步重命名链路与卡面正文的既有断言
This commit is contained in:
@@ -7494,6 +7494,48 @@ iframe.preview-frame {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/*
|
||||
* 卡面名称:所有类型卡统一在底部显示正式资源名(`resource.label`)。
|
||||
*
|
||||
* 名称来自现有正式命名链路——生成时 `assetName` 参与落盘名、用户重命名改写同一份
|
||||
* `localPath`,所以这里只消费投影 label,不另建第二份显示名。名称长了由省略号截断,
|
||||
* 完整名称挂在 `title` 上;`pointer-events: none` 保证点击仍然落到整卡的选中按钮。
|
||||
*
|
||||
* 圆角用 `inherit` 拿卡片自己的圆角(总览态与栏目态卡片圆角不同),再把上沿两角归零:
|
||||
* 卡片本体是 `overflow: visible`,名称条不跟着圆角就会在卡片圆角外露出直角。
|
||||
*/
|
||||
.game-resource-card-name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
padding: 10px 8px 4px;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(255 253 250 / 96%) 52%,
|
||||
rgb(255 253 250 / 0%)
|
||||
);
|
||||
color: #4e382f;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.01em;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 视频 / 音频卡右下角是 34px 播放钮(+9px 内边距),名称的右侧不钻到按钮下面。 */
|
||||
.game-resource-card[data-preview-kind='video'] .game-resource-card-name,
|
||||
.game-resource-card[data-preview-kind='audio'] .game-resource-card-name {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.game-resource-card-type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -7522,11 +7564,12 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
/* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。
|
||||
左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色
|
||||
把这条关系与光环联系起来。 */
|
||||
卡片底部整条是卡面名称(`.game-resource-card-name`),所以血缘角标压在名称条之上;
|
||||
右下角仍是媒体播放钮,最大宽度按右侧让出播放钮的宽度。
|
||||
用「当前版本」同一支橙色把这条关系与光环联系起来。 */
|
||||
.game-resource-card-lineage-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
bottom: 32px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
@@ -7638,27 +7681,13 @@ iframe.preview-frame {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-resource-card-document-summary {
|
||||
display: -webkit-box;
|
||||
max-height: 100%;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
color: #674c41;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
text-align: left;
|
||||
/* 文档卡预览改为"前几行"(最多 3 行),必须保留换行:折成一行就看不出结构了。 */
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/*
|
||||
* 代码文件卡:不显示文件内容,只给代码图标 + 扩展名标签。
|
||||
* 与 `.game-resource-card-version-visual` 共用同一套居中排布口径,视觉上仍是同一族卡片。
|
||||
* 代码文件卡与文档卡:卡面都不显示正文 —— 代码卡给代码图标 + 扩展名标签,
|
||||
* 文档卡只给居中的文档图标(正文只在独立的文档详情浮层里按原文 / 代码 / UI JSON 渲染)。
|
||||
* 两者与 `.game-resource-card-version-visual` 共用同一套居中排布口径,视觉上仍是同一族卡片。
|
||||
*/
|
||||
.game-resource-card-code-visual {
|
||||
.game-resource-card-code-visual,
|
||||
.game-resource-card-document-visual {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
@@ -298,7 +298,6 @@ import {
|
||||
type ProjectResourceCardPreviewState,
|
||||
projectResourceCardPreviewVariant,
|
||||
projectResourceCodeTypeLabel,
|
||||
projectResourceDocumentPreviewText,
|
||||
projectResourceJsonPresentation,
|
||||
} from './resourceCardPreviewModel';
|
||||
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
|
||||
@@ -793,17 +792,16 @@ const ResourceCard = memo(function ResourceCard({
|
||||
: resourceCategoryLabel;
|
||||
/** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
|
||||
const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
|
||||
const documentPreview =
|
||||
!jsonPresentation &&
|
||||
preview.status === 'loaded' &&
|
||||
preview.preview.content !== undefined &&
|
||||
previewVariant !== null &&
|
||||
previewVariant !== 'code'
|
||||
? projectResourceDocumentPreviewText(
|
||||
preview.preview.content,
|
||||
previewVariant === 'markdown',
|
||||
)
|
||||
: '';
|
||||
/**
|
||||
* 文档卡(markdown / 纯文本,且不是 JSON 或 UI 设计):**卡面不显示正文**,
|
||||
* 只给居中的文档图标。
|
||||
*
|
||||
* 判据与代码卡同源,只看路径分流(`previewVariant`),**不依赖是否读到内容**:
|
||||
* 等读完再决定画什么,只会把"半截正文"和多余的重排一起留在链路上。
|
||||
* 正文仍在独立详情浮层里按原文 / 代码 / UI JSON 口径读取,读取预算与权限不变。
|
||||
*/
|
||||
const isDocumentCard =
|
||||
!jsonPresentation && previewVariant !== null && previewVariant !== 'code';
|
||||
|
||||
useEffect(() => {
|
||||
const element = cardRef.current;
|
||||
@@ -983,13 +981,15 @@ const ResourceCard = memo(function ResourceCard({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (previewVariant !== null && documentPreview) {
|
||||
/**
|
||||
* 文档卡:居中图标,**不显示正文摘要**(旧实现把前 3 行正文铺在卡面上,
|
||||
* 与"卡面不显示无效正文片段"冲突)。原文预览、代码块与 UI JSON 识别都留在
|
||||
* 独立详情浮层,卡面这条链路不再读内容做展示。
|
||||
*/
|
||||
if (isDocumentCard) {
|
||||
return (
|
||||
<span
|
||||
className="game-resource-card-document-summary"
|
||||
data-document-preview-variant={previewVariant}
|
||||
>
|
||||
{documentPreview}
|
||||
<span className="game-resource-card-document-visual">
|
||||
<FileText size={30} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1073,6 +1073,22 @@ const ResourceCard = memo(function ResourceCard({
|
||||
<span className="game-resource-card-visual" aria-hidden="true">
|
||||
{visual}
|
||||
</span>
|
||||
{/*
|
||||
卡面名称:**所有类型卡统一显示正式资源名**(`resource.label`)。
|
||||
|
||||
它来自现有正式命名链路——manifest 资产的 `localPath` 就是生成时的
|
||||
`assetName` 落盘名,用户重命名也会改写同一份 `localPath`,因此这里消费
|
||||
投影 label 即可,不从临时输入或历史任务名另取一份显示名,也不持久化第二份
|
||||
名称。名称长了由 CSS 单行省略,完整名称在 `title` 上;`data-resource-name`
|
||||
是给端到端验收的稳定判据。
|
||||
*/}
|
||||
<span
|
||||
className="game-resource-card-name"
|
||||
data-resource-name={resource.label}
|
||||
title={resource.label}
|
||||
>
|
||||
{resource.label}
|
||||
</span>
|
||||
<span
|
||||
className="game-resource-card-type-badge"
|
||||
data-resource-type={cardTypeLabel}
|
||||
|
||||
+5
-63
@@ -330,9 +330,12 @@ export function projectResourceCardPreviewKind(
|
||||
* 文档类卡片(`document` / `code`)的卡面分流结论。
|
||||
*
|
||||
* 三种落点:
|
||||
* - `markdown`:显示前几行,预览前做轻量标记清理;
|
||||
* - `markdown`:卡面只画居中文档图标,正文只在独立详情浮层按 Markdown 渲染;
|
||||
* - `code`:**完全不读内容**,卡面改画代码图标 + 类型标签;
|
||||
* - `plain-text`:显示前几行,不做标记清理。
|
||||
* - `plain-text`:卡面只画居中文档图标,正文只在独立详情浮层按原文展示。
|
||||
*
|
||||
* 三者都判**路径**,不依赖是否读到内容:卡面本来就不铺正文,等读完再决定画什么
|
||||
* 只会把"半截正文"和多余的重排一起留在链路上。
|
||||
*/
|
||||
export type ProjectResourceCardPreviewVariant =
|
||||
| 'markdown'
|
||||
@@ -400,64 +403,3 @@ export function projectResourceCardPreviewIdentity(input: {
|
||||
input.previewVersion ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 卡面文档预览的最大行数(超出由 CSS 省略号截断)。 */
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT = 3;
|
||||
/** 单行预览的最大字符数,超出截断成省略号。 */
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH = 72;
|
||||
|
||||
/**
|
||||
* Markdown 的**轻量**清理:只去掉标记符号,不是渲染器。
|
||||
*
|
||||
* - 去掉围栏标记行(``` / ~~~),保留代码内容本身;
|
||||
* - 去掉行首标题 `#`、引用 `>` 与列表符号(保留缩进层级);
|
||||
* - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字;
|
||||
* - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。
|
||||
*/
|
||||
function stripMarkdownMarkers(content: string): string {
|
||||
return content
|
||||
.replace(/^\s*(?:```|~~~).*$/gmu, '')
|
||||
.replace(/^\s{0,3}#{1,6}\s*/gmu, '')
|
||||
.replace(/^\s{0,3}>\s?/gmu, '')
|
||||
.replace(/^\s*[-+*]\s+/gmu, '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/`{1,3}([^`]*)`{1,3}/gu, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/gu, '$1')
|
||||
.replace(/__([^_]+)__/gu, '$1')
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2')
|
||||
.replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2');
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档卡面的前几行预览文本。
|
||||
*
|
||||
* `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。
|
||||
* 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH`
|
||||
* 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。
|
||||
*/
|
||||
export function projectResourceDocumentPreviewText(
|
||||
content: string,
|
||||
isMarkdown: boolean,
|
||||
): string {
|
||||
const normalized = isMarkdown ? stripMarkdownMarkers(content) : content;
|
||||
const lines: string[] = [];
|
||||
for (const rawLine of normalized.split(/\r?\n/u)) {
|
||||
const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, '');
|
||||
const trimmed = line.trimStart();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const indent = line.slice(0, line.length - trimmed.length).slice(0, 2);
|
||||
const text = `${indent}${trimmed}`;
|
||||
lines.push(
|
||||
text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH
|
||||
? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…`
|
||||
: text,
|
||||
);
|
||||
if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -2979,7 +2979,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
await showResourcePage('角色与对象');
|
||||
let heroDetailButton = await findResourceSelectButton('hero.png');
|
||||
const heroCard = heroDetailButton.closest('.game-resource-card');
|
||||
expect(heroCard?.textContent).not.toContain('hero.png');
|
||||
// 卡面名称就是正式资源名(manifest `localPath` 的文件名,生成时来自 assetName、
|
||||
// 重命名时同步改写),长名由 CSS 省略、完整名在 `title` 上。
|
||||
const heroName = heroCard?.querySelector('.game-resource-card-name');
|
||||
expect(heroName?.textContent).toBe('hero.png');
|
||||
expect(heroName?.getAttribute('title')).toBe('hero.png');
|
||||
expect(heroName?.getAttribute('data-resource-name')).toBe('hero.png');
|
||||
// 卡面仍然只给"名称 + 类型",不铺完整路径与来源这类详细文本。
|
||||
expect(heroCard?.textContent).not.toContain('assets/hero.png');
|
||||
expect(heroCard?.textContent).not.toContain('Agent 生成');
|
||||
// 角标显示的是**资源类型**(功能分类),等于它所在的画布栏目;不再是"图片"这类媒体类型。
|
||||
@@ -3003,13 +3009,21 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
await showResourcePage('待归类');
|
||||
act(() => observer.triggerVisible());
|
||||
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
|
||||
// 卡面正文按扩展名走文档分支(媒体类型),但角标是资源类型:`design-document` 不在
|
||||
// canonical 目录里 → 落「待归类」,与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。
|
||||
// 文档卡卡面**不铺正文**:只显示居中图标 + 正式资源名,正文只在独立的文档详情浮层里读。
|
||||
const documentCard = (
|
||||
await findResourceSelectButton('design.md')
|
||||
).closest<HTMLElement>('.game-resource-card');
|
||||
expect(
|
||||
documentSummary
|
||||
.closest('.game-resource-card')
|
||||
?.querySelector('[data-resource-type="待归类"]'),
|
||||
documentCard?.querySelector('.game-resource-card-name')?.textContent,
|
||||
).toBe('design.md');
|
||||
expect(
|
||||
documentCard?.querySelector('.game-resource-card-document-visual'),
|
||||
).not.toBeNull();
|
||||
expect(documentCard?.textContent).not.toContain('这是安全的卡片正文摘要。');
|
||||
// 角标仍是资源类型:`design-document` 不在 canonical 目录里 → 落「待归类」,
|
||||
// 与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。
|
||||
expect(
|
||||
documentCard?.querySelector('[data-resource-type="待归类"]'),
|
||||
).not.toBeNull();
|
||||
await showResourcePage('角色与对象');
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createGameCreationAppManifest,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
installResizeObserverStub,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', async () => ({
|
||||
...(await vi.importActual<typeof import('@tauri-apps/api/core')>(
|
||||
'@tauri-apps/api/core',
|
||||
)),
|
||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||
window.__TAURI__!.core.invoke(command, args),
|
||||
}));
|
||||
|
||||
const PROJECT_PATH = '/tmp/workbench-card-name';
|
||||
/**
|
||||
* 生成落盘名的真实形状:`assets/canvas-generated/{毫秒}-{assetName}.{ext}`。
|
||||
* 卡面名称必须消费这条正式链路的结果,而不是另造一份显示名。
|
||||
*/
|
||||
const GENERATED_IMAGE_NAME =
|
||||
'1758000000000-夜行侠主角三视图与战斗姿态设定稿.png';
|
||||
const GENERATED_IMAGE_PATH = `assets/canvas-generated/${GENERATED_IMAGE_NAME}`;
|
||||
const DOCUMENT_BODY = '# 玩法摘要\n\n这段正文只应出现在独立详情浮层里。';
|
||||
|
||||
function createCardNameManifest(): GameCreationAppManifest {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-card-name',
|
||||
'卡片名称测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-scene',
|
||||
kind: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: GENERATED_IMAGE_PATH,
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-notes',
|
||||
kind: 'design-document',
|
||||
mediaType: 'text/markdown',
|
||||
localPath: 'memory/设计文档.md',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-code',
|
||||
kind: 'code',
|
||||
mediaType: 'text/html',
|
||||
localPath: 'game/index.html',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-bgm',
|
||||
kind: 'background-music',
|
||||
mediaType: 'audio/mpeg',
|
||||
localPath: 'assets/theme.mp3',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-initial',
|
||||
parentVersionId: null,
|
||||
projectRevision: 1,
|
||||
resourceBindings: [],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function installInvoke(
|
||||
implementation: (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const invoke = vi.fn(implementation);
|
||||
(
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: typeof invoke } };
|
||||
}
|
||||
).__TAURI__ = { core: { invoke } };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
function installManifestInvoke(projectId: string) {
|
||||
return installInvoke(async (command, args) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
const ids = (args?.resources as Array<{ resourceId: string }>).map(
|
||||
(item) => item.resourceId,
|
||||
);
|
||||
return {
|
||||
resourceIds: ids,
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
producerAssignments: [],
|
||||
dependencyDepths: ids.map((resourceId) => ({
|
||||
resourceId,
|
||||
dependencyDepth: 0,
|
||||
})),
|
||||
connectionIndex: ids.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [],
|
||||
})),
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === 'read_local_project_resource_canvas_layout' ||
|
||||
command === 'update_local_project_resource_canvas_layout'
|
||||
) {
|
||||
const layout = {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: args?.mode,
|
||||
revision: Number(args?.expectedRevision ?? 0) + 1,
|
||||
positions: args?.positions ?? [],
|
||||
updatedAt: 1,
|
||||
};
|
||||
return command.startsWith('update_')
|
||||
? { status: 'updated', layout }
|
||||
: layout;
|
||||
}
|
||||
if (command === 'read_local_project_image_preview') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
mediaType: 'image/png',
|
||||
byteLen: 12,
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_text_preview') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
mediaType: 'text/markdown',
|
||||
byteLen: DOCUMENT_BODY.length,
|
||||
content: DOCUMENT_BODY,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_media_preview') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
mediaType: 'audio/mpeg',
|
||||
byteLen: 32,
|
||||
dataUrl: 'data:audio/mpeg;base64,SUQz',
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === 'list_pending_local_project_resource_edits' ||
|
||||
command === 'list_local_project_asset_generations'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */
|
||||
async function openResourceBookCategory(label: string) {
|
||||
if (document.querySelector('[data-resource-book-view="child"]')) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('[data-resource-book-view="main"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
}
|
||||
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('[data-resource-book-view="child"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 卡面名称节点断言:文本、`title` 与稳定 DOM 判据都是同一个正式资源名。 */
|
||||
async function expectCardName(name: string) {
|
||||
const card = (await findResourceSelectButton(name)).closest<HTMLElement>(
|
||||
'.game-resource-card',
|
||||
)!;
|
||||
const nameNode = card.querySelector('.game-resource-card-name');
|
||||
expect(nameNode?.textContent).toBe(name);
|
||||
expect(nameNode?.getAttribute('title')).toBe(name);
|
||||
expect(nameNode?.getAttribute('data-resource-name')).toBe(name);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderWorkbench(manifest: GameCreationAppManifest) {
|
||||
return render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stylesSource() {
|
||||
return readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
/** 取 CSS 源文件里某条规则的声明体。 */
|
||||
function ruleBody(styles: string, selector: string) {
|
||||
const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles);
|
||||
expect(match, `${selector} 规则缺失`).not.toBeNull();
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('资源卡名称与文档卡卡面', () => {
|
||||
/**
|
||||
* 名称口径:所有类型卡都显示**正式资源 label**——manifest `localPath` 的文件名。
|
||||
* 生成时它是 `assetName` 的落盘名,用户重命名会改写同一份 `localPath`,
|
||||
* 卡片不另取临时输入或历史任务名,也不持久化第二份显示名。
|
||||
*/
|
||||
it('所有类型卡都显示正式资源名,且不把目录路径铺到卡面上', async () => {
|
||||
installResizeObserverStub();
|
||||
installManifestInvoke('workbench-card-name');
|
||||
renderWorkbench(createCardNameManifest());
|
||||
|
||||
await openResourceBookCategory('角色与对象');
|
||||
const heroCard = await expectCardName('hero.png');
|
||||
expect(heroCard.textContent).not.toContain('assets/');
|
||||
expect(heroCard.textContent).not.toContain('Agent 生成');
|
||||
|
||||
await openResourceBookCategory('场景与环境');
|
||||
// 长名不被截断在数据层:这里拿到的是完整名,单行省略只由 CSS 负责,
|
||||
// 用户悬停 `title` 仍能读到完整名称。
|
||||
const sceneCard = await expectCardName(GENERATED_IMAGE_NAME);
|
||||
expect(sceneCard.textContent).not.toContain('assets/');
|
||||
|
||||
await openResourceBookCategory('音频');
|
||||
await expectCardName('theme.mp3');
|
||||
|
||||
await openResourceBookCategory('待归类');
|
||||
await expectCardName('设计文档.md');
|
||||
await expectCardName('index.html');
|
||||
|
||||
await openResourceBookCategory('项目版本');
|
||||
await expectCardName('版本 1');
|
||||
});
|
||||
|
||||
it('文档卡以居中图标呈现,正文只在独立详情里读', async () => {
|
||||
installResizeObserverStub();
|
||||
const invoke = installManifestInvoke('workbench-card-name');
|
||||
renderWorkbench(createCardNameManifest());
|
||||
|
||||
await openResourceBookCategory('待归类');
|
||||
const documentCard = await expectCardName('设计文档.md');
|
||||
// 先证明正文**确实读到过**:读取链路没有被这次改动掐掉,卡面仍旧不铺摘要。
|
||||
await waitFor(() =>
|
||||
expect(documentCard.getAttribute('data-preview-status')).toBe('loaded'),
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command, args]) =>
|
||||
command === 'read_local_project_text_preview' &&
|
||||
args?.relativePath === 'memory/设计文档.md',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
documentCard.querySelector('.game-resource-card-document-visual'),
|
||||
).not.toBeNull();
|
||||
expect(documentCard.textContent).not.toContain(DOCUMENT_BODY);
|
||||
expect(documentCard.textContent).not.toContain(
|
||||
'这段正文只应出现在独立详情浮层里',
|
||||
);
|
||||
expect(
|
||||
documentCard.querySelector('.game-resource-card-document-summary'),
|
||||
).toBeNull();
|
||||
// 代码卡同样只在卡面给图标 + 类型标签,名称照常显示。
|
||||
const codeCard = await expectCardName('index.html');
|
||||
expect(
|
||||
codeCard.querySelector('.game-resource-card-code-visual'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 独立详情不回归:选中文档卡 → 工具条「预览」→ 正文按原文读到浮层里。
|
||||
fireEvent.click(
|
||||
within(documentCard).getByRole('button', {
|
||||
name: '选中资源:待归类 设计文档.md',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '预览' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '文档预览' });
|
||||
await waitFor(() =>
|
||||
expect(dialog.textContent).toContain('这段正文只应出现在独立详情浮层里'),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* jsdom 不加载样式表:长名单行省略、名称条不挡点击这些声明本身在源码层钉住;
|
||||
* 真机像素表现仍需人工核验。
|
||||
*/
|
||||
it('卡面名称的省略与层级声明固定在样式表里', () => {
|
||||
const styles = stylesSource();
|
||||
const nameRule = ruleBody(styles, '\\.game-resource-card-name');
|
||||
expect(nameRule).toMatch(/overflow:\s*hidden/u);
|
||||
expect(nameRule).toMatch(/text-overflow:\s*ellipsis/u);
|
||||
expect(nameRule).toMatch(/white-space:\s*nowrap/u);
|
||||
expect(nameRule).toMatch(/pointer-events:\s*none/u);
|
||||
// 名称条铺在整卡选中按钮之上、右下角播放钮之下,两边都不能被盖住。
|
||||
expect(nameRule).toMatch(/z-index:\s*2/u);
|
||||
expect(
|
||||
ruleBody(
|
||||
styles,
|
||||
"\\.game-resource-card\\[data-preview-kind='audio'\\] \\.game-resource-card-name",
|
||||
),
|
||||
).toMatch(/padding-right:\s*50px/u);
|
||||
// 旧的卡面正文摘要样式必须随正文一起消失,不留墓碑规则。
|
||||
expect(styles).not.toContain('.game-resource-card-document-summary');
|
||||
// 文档卡与代码卡共用同一套居中排布。
|
||||
const documentVisualRule = ruleBody(
|
||||
styles,
|
||||
'\\.game-resource-card-code-visual,\\s*\\.game-resource-card-document-visual',
|
||||
);
|
||||
expect(documentVisualRule).toMatch(/place-items:\s*center/u);
|
||||
expect(documentVisualRule).toMatch(/align-content:\s*center/u);
|
||||
});
|
||||
});
|
||||
@@ -231,11 +231,16 @@ describe('素材重命名前端链路', () => {
|
||||
|
||||
rendered.rerenderWith(renamedManifest);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
const renamedCard = screen
|
||||
.getByRole('button', {
|
||||
name: '选中资源:角色与对象 hero-v2.png',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
})
|
||||
.closest('.game-resource-card');
|
||||
// 卡面名称与重命名后的 manifest `localPath` 同步:名称就是正式文件名的投影,
|
||||
// 不存在第二份需要一起改的显示名。
|
||||
const nameNode = renamedCard?.querySelector('.game-resource-card-name');
|
||||
expect(nameNode?.textContent).toBe('hero-v2.png');
|
||||
expect(nameNode?.getAttribute('title')).toBe('hero-v2.png');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user