Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
T
suzmii cb76aadb48
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 18m26s
AGC 输入区三处观感修复:AI 润色归位、@ 面板筛选条单行、引用 chip 原子化
- styles.css:把 direct-codex composer 下广播式的 `.project-supervisor-composer button` 规则
  收窄到 `.project-supervisor-submit-button`(含 svg 那条与 media query 里那条),
  并给 `.resource-reference-input-actions` 补 `grid-column: 1 / -1` 让输入区操作排独占一行
- styles.css:`.resource-reference-input-actions` / `.resource-reference-input-at` 撤掉绝对定位,
  操作排回到网格文档流,与输入框右下角对齐,不再浮在文本区中间
- styles.css:`.resource-reference-input-polish` 与 `.resource-reference-input-restore` 并入
  `@` 按钮同一条尺寸规则(28px 方钮、8px 圆角、同 hover/disabled 观感)
- styles.css:输入区状态行(润色中/润色失败)改为与应用排同一行,不再单独占一行顶高 22.8px
- styles.css:direct-codex 输入框 padding 收为 `12px 15px 6px`,窄屏 `10px 12px 6px`
- styles.css:`.resource-reference-chip` 的 `×` 删除按钮不再被 broadcast 规则摘出 chip
- PlatformSegmentedTabs.tsx:分段页签 item 加 `whitespace-nowrap`,中文不再逐字竖排
- packages/shared/styles.css:补 `.scrollbar-hide` 实现(该类名一直被 `layout="scroll"` 引用但仓库未定义)
- ResourceReferenceNode.tsx:`getTextContent()` 改为单个 U+FFFC 占位符并显式实现
  `getTextContentSize()`,引用成为真正的原子内联节点(1 个引用 = 1 个字符)
- tests/resourceReferenceInput.test.tsx:新增「1 个引用 = 1 个字符」与「chip 自带删除、一次删净」两条断言
- tests/appSurface/project-development.suite.ts:把 composer 按钮样式断言收窄到提交按钮,
  并新增一条反向断言,钉住「广播到 composer 下所有 button」的规则不再出现
2026-09-10 19:05:57 +08:00

606 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
currentIterationVersionAssets,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
} from '../src/features/project-workspace/resourceReferences';
function asset(
id: string,
kind: string,
mediaType: string,
localPath: string,
): GameCreationAppAssetManifestEntry {
return {
id,
kind,
mediaType,
localPath,
source: { kind: 'uploaded' },
};
}
function iterationVersion(
versionId: string,
resourceIds: string[],
parentVersionId: string | null = null,
): GameIterationVersion {
return {
versionId,
parentVersionId,
projectRevision: 1,
resourceBindings: resourceIds.map((resourceId, index) => ({
slotId: `slot-${index}`,
resourceId,
})),
createdReason: 'initial',
createdAt: 1,
};
}
async function settleComposer() {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
/**
* 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本:
* DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。
*/
function composerEditor(): {
getEditorState: () => { read: <T>(fn: () => T) => T };
} {
const element = screen.getByLabelText('聊天') as HTMLElement & {
__lexicalEditor?: {
getEditorState: () => { read: <T>(fn: () => T) => T };
};
};
const editor = element.__lexicalEditor;
if (!editor) throw new Error('lexical editor instance not found');
return editor;
}
function editorTextSize() {
return composerEditor()
.getEditorState()
.read(() => $getRoot().getTextContentSize());
}
const assets = [
asset('hero', 'character', 'image/png', 'assets/hero.png'),
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
];
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
test('opens the asset picker, supports multi-select, and inserts stable references', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('dialog', { name: '选择素材' })).not.toBeNull();
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('option', { name: /enemy/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const draft = onChange.mock.calls.at(-1)?.[0];
expect(draft?.text).toBe('@hero @enemy');
expect(draft?.references.map((reference) => reference.resourceId)).toEqual([
'hero',
'enemy',
]);
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('loads image thumbnails through the controlled native preview command', async () => {
const user = userEvent.setup();
const invoke = vi.fn().mockResolvedValue({
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
});
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
render(
<StrictMode>
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</StrictMode>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await waitFor(() => {
expect(
document.querySelector('.resource-reference-picker-list img'),
).not.toBeNull();
});
expect(invoke).toHaveBeenCalledWith(
'read_local_project_image_preview',
expect.objectContaining({
projectPath: 'C:/project',
relativePath: 'assets/hero.png',
}),
);
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__;
});
test('filters candidate references by name, id, kind, and functional category', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
expect(resourceReferenceCategory(hero)).toBe('character');
expect(resourceReferenceCategory(theme)).toBe('audio');
expect(hero.tags).toEqual([]);
});
test('exposes the six functional categories plus all as the single filter source', () => {
expect(RESOURCE_REFERENCE_FILTERS).toEqual([
{ id: 'all', label: '全部' },
{ id: 'ui-interaction', label: 'UI 交互' },
{ id: 'character', label: '角色与对象' },
{ id: 'scene', label: '场景与环境' },
{ id: 'audio', label: '音频' },
{ id: 'document', label: '文档' },
{ id: 'unclassified', label: '待归类' },
]);
});
test('projects explicit category and manifest tags onto the reference', () => {
const hero = resourceReferenceFromAsset(
{
...assets[0]!,
category: 'scene',
tags: ['主舞台', '日夜'],
},
'asset-picker',
);
expect(hero.category).toBe('scene');
expect(hero.tags).toEqual(['主舞台', '日夜']);
expect(resourceReferenceMatchesCategoryFilter(hero, 'scene')).toBe(true);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
false,
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'all')).toBe(true);
});
test('falls back to the kind-derived category when the manifest asset omits it', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const unclassified = resourceReferenceFromAsset(
{
...assets[0]!,
kind: 'image',
},
'asset-picker',
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
true,
);
expect(resourceReferenceCategory(unclassified)).toBe('unclassified');
});
test('resource-card insertion uses the shared structured reference event', () => {
const reference = resourceReferenceFromAsset(assets[0]!, 'resource-card');
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero', 'bad id', '../secret'],
sourcePath: '/assets/hero.png?token=secret',
html: '<button>secret</button>',
},
}),
).toEqual({
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero'],
sourcePath: '/assets/hero.png',
},
});
expect(
parseLocalGamePreviewInspectMessage({
type: 'unknown',
action: 'selected',
}),
).toBeNull();
});
test('runtime-region references travel through the same structured event', () => {
const reference: ChatReference = {
type: 'runtime-region',
label: '开始按钮',
runId: 'preview-3101',
elementTag: 'button',
text: '开始游戏',
resourceIds: ['hero'],
source: 'runtime-picker',
};
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('removing a chip keeps the remaining text editable', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await screen.findByRole('button', { name: '移除引用 hero' });
await user.click(screen.getByRole('button', { name: '移除引用 hero' }));
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
});
});
test('counts one inserted reference as a single character in the editor text model', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
// 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格
// 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算,
// 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。
// 提交用的结构化引用另走 `references`,不受这个占位字符影响。
expect(editorTextSize()).toBe(2);
});
test('keeps the whole reference after one delete and drops its data in one step', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
// chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上
// `移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。
const chip = document.querySelector('[data-resource-reference-id="hero"]');
expect(chip).not.toBeNull();
expect(chip?.textContent).toBe('@hero');
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero');
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe(
'hero',
);
// 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。
await user.click(deleteButton);
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).toBeNull();
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('');
});
test('exposes the current-version and all-canvas scopes as the only two tabs', () => {
expect(RESOURCE_REFERENCE_SCOPES).toEqual([
{ id: 'current-version', label: '当前版本素材' },
{ id: 'all-canvas', label: '全部画布素材' },
]);
});
test('resolves the current version from activeVersionId or the newest manifest version', () => {
const versions = [
iterationVersion('v1', ['hero']),
iterationVersion('v2', ['theme'], 'v1'),
];
expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1');
expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2');
expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe(
'v2',
);
expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull();
expect(resolveActiveIterationVersion([], null)).toBeNull();
expect(resolveActiveIterationVersion(undefined, null)).toBeNull();
});
test('derives current-version assets from bindings and drops dangling bindings', () => {
const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])];
expect(
currentIterationVersionAssets(assets, versions, 'v1').map(
(entry) => entry.id,
),
).toEqual(['hero']);
expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual(
[],
);
expect(currentIterationVersionAssets(assets, [], null)).toEqual([]);
expect(
currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'),
).toEqual([]);
});
test('keeps an independent search and filter state per picker scope', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[
iterationVersion('v1', ['hero', 'enemy']),
iterationVersion('v2', ['theme'], 'v1'),
]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
await user.type(screen.getByLabelText('搜索当前版本素材'), 'the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的搜索与筛选,不受当前版本页签影响。
const allCanvasSearch = screen.getByLabelText(
'搜索全部画布素材',
) as HTMLInputElement;
expect(allCanvasSearch.value).toBe('');
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
await user.type(allCanvasSearch, 'hero');
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(
(screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value,
).toBe('the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
});
test('shows an empty state when the active version has no bound resources', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
activeVersionId="v1"
versions={[iterationVersion('v1', ['deleted-asset'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
expect(screen.queryByRole('option')).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
});
test('shows the current-version empty state when the project has no versions', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
});
test('refreshes chip and candidate display names after a resource rename', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const renamedAssets = [
asset('hero', 'character', 'image/png', 'assets/hero-final.png'),
assets[1]!,
assets[2]!,
];
render(
<ResourceReferenceInput
value="用这个角色"
references={[resourceReferenceFromAsset(assets[0]!, 'asset-picker')]}
onChange={onChange}
assets={renamedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(
document.querySelector('.resource-reference-chip-label')?.textContent,
).toBe('hero-final');
});
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe(
'hero-final',
);
});
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero /u })).toBeNull();
});
test('restores a cross-session draft with the caret at the end of the text', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const { rerender } = render(
<ResourceReferenceInput
value="上一个会话的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 切换 / 重开会话:外部草稿被整体替换。
rerender(
<ResourceReferenceInput
value="恢复出来的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero');
});
});