收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -20,22 +20,24 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
refreshKey,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
'data-testid'?: string;
}) => (
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
data-testid={dataTestId}
/>
) : null
),
) : null,
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -79,6 +81,24 @@ function stubReferenceImageUpload(dataUrl: string) {
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
render(
<PuzzleResultView
session={{ ...createSession(), draft: null }}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const noticePanel = screen
.getByText('还没有可编辑的拼图草稿')
.closest('.platform-subpanel');
expect(noticePanel?.className).toContain('rounded-[1rem]');
expect(noticePanel?.className).toContain('sm:p-5');
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
@@ -227,8 +247,27 @@ describe('PuzzleResultView', () => {
);
openPuzzleLevelsTab();
const levelImage = screen.getByRole('img', { name: '雨夜猫街' });
const mediaFrame = levelImage.closest('div.relative');
expect(mediaFrame?.className).toContain('aspect-[4/3]');
expect(mediaFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(mediaFrame?.className).toContain('rounded-none');
expect(screen.getByText('雨夜猫街')).toBeTruthy();
const levelTitleButton = within(
screen.getByLabelText('拼图关卡列表'),
).getByRole('button', { name: '雨夜猫街' });
const levelCard = levelTitleButton.closest('.platform-subpanel');
expect(levelCard?.className).toContain('rounded-[1.35rem]');
expect(levelCard?.className).toContain('p-0');
expect(levelCard?.className).toContain('overflow-hidden');
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByText('雨夜猫街'));
const levelDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(levelDialog).getByText('关卡名称').className).toContain(
'tracking-[0.18em]',
);
});
test('result action bar restores draft trial entry', () => {
@@ -334,10 +373,17 @@ describe('PuzzleResultView', () => {
target: { value: '暖灯猫街合集' },
});
expect(screen.getByText('保存中').className).toContain(
'border-[var(--platform-warm-border)]',
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('已自动保存').className).toContain(
'border-emerald-200',
);
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
@@ -356,6 +402,38 @@ describe('PuzzleResultView', () => {
);
});
test('auto save failure badge uses PlatformPillBadge danger chrome', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockRejectedValue(
new Error('保存失败'),
);
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街异常版' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
const failureBadge = screen
.getAllByText('保存失败')
.find((element) => element.tagName.toLowerCase() === 'span');
expect(failureBadge?.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
});
test('opens an independent level detail dialog for generation and test play', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
@@ -387,7 +465,9 @@ describe('PuzzleResultView', () => {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确定' }),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
@@ -405,7 +485,9 @@ describe('PuzzleResultView', () => {
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
expect(
screen.getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
@@ -421,6 +503,19 @@ describe('PuzzleResultView', () => {
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const generationBadges = within(levelList)
.getAllByText('生成中')
.map((element) => element.closest('span'));
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-white/94'),
),
).toBe(true);
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-amber-100'),
),
).toBe(true);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
@@ -439,7 +534,9 @@ describe('PuzzleResultView', () => {
name: '关闭关卡图片预览',
}),
);
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '更换参考图' }),
).toBeTruthy();
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
@@ -701,7 +798,9 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByText('还有关卡画面正在生成。'),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
).toHaveProperty('disabled', true);
@@ -837,11 +936,30 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('发布检查').className).toContain(
'tracking-[0.18em]',
);
expect(within(publishDialog).getByText('封面关卡').className).toContain(
'tracking-[0.18em]',
);
const coverImage = within(publishDialog).getByRole('img', {
name: '雨夜猫街',
});
expect(coverImage.closest('div.relative')?.className).toContain(
'aspect-square',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'bg-white/68',
);
expect(coverImage.getAttribute('data-refresh-key')).toBe(
'2026-04-26T10:00:00.000Z:/puzzle/candidate-1.png:1',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: /发布到广场/u },
),
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
);
expect(onExecuteAction).toHaveBeenCalledWith(
@@ -1286,7 +1404,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1302,6 +1421,14 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceImage = within(dialog).getByAltText('拼图参考图');
const referenceRow = referenceImage.closest('div')?.parentElement;
expect(referenceRow?.className).toContain('flex items-center gap-3');
expect(referenceRow?.className).toContain('bg-white/72');
expect(within(dialog).getByText('已选择参考图').className).toContain(
'truncate',
);
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
@@ -1313,7 +1440,8 @@ describe('PuzzleResultView', () => {
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
referenceImageSrc:
'/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
@@ -1367,7 +1495,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1387,18 +1516,22 @@ describe('PuzzleResultView', () => {
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
'checked',
true,
);
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toHaveProperty('checked', true);
expect(
within(dialog).getByRole('button', { name: '选择历史图片' }),
).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求提示词'), {
target: { value: '只重绘第一关猫街画面' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.change(
within(dialog).getByLabelText('画面AI重绘要求提示词'),
{
target: { value: '只重绘第一关猫街画面' },
},
);
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1429,7 +1562,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1448,7 +1582,9 @@ describe('PuzzleResultView', () => {
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1497,14 +1633,18 @@ describe('PuzzleResultView', () => {
});
await waitFor(() => {
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
});
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
'src',
uploadedDataUrl,
);
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1539,11 +1679,10 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
const referenceInput = within(dialog).getByLabelText('上传描述参考图', {
selector: 'input',
});
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
fireEvent.change(referenceInput, {
target: {
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
},
@@ -1556,7 +1695,9 @@ describe('PuzzleResultView', () => {
}),
).toBeTruthy();
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1628,7 +1769,19 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
const creativeDraftInput = screen.getByLabelText('智能修订拼图草稿');
const creativeDraftPanel = creativeDraftInput.closest('.platform-subpanel');
const creativeDraftIconBadge = creativeDraftPanel?.querySelector(
'span[aria-hidden="true"]',
);
expect(creativeDraftPanel?.className).toContain('rounded-[1.35rem]');
expect(creativeDraftPanel?.className).toContain('sm:p-4');
expect(creativeDraftIconBadge?.className).toContain('h-9');
expect(creativeDraftIconBadge?.className).toContain('rounded-full');
expect(creativeDraftIconBadge?.className).toContain('bg-white/72');
fireEvent.change(creativeDraftInput, {
target: { value: '把标题改得轻松一点' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));