Files
Genarrative/src/components/visual-novel-result/VisualNovelResultView.test.tsx
kdletters 402b847c7f 继续收口编辑器空态与暗色动作按钮
视觉小说实体列表空态复用 PlatformEmptyState
角色素材工作室局部按钮改为委托 PlatformActionButton
RPG大编辑器局部按钮改为委托 PlatformActionButton
更新 PlatformUiKit 收口文档与团队决策记录
2026-06-11 01:52:47 +08:00

312 lines
10 KiB
TypeScript

/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
createVisualNovelBackgroundMusicTask: vi.fn(),
createVisualNovelSoundEffectTask: vi.fn(),
generateVisualNovelImageAsset: vi.fn(),
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
publishVisualNovelBackgroundMusicAsset: vi.fn(),
publishVisualNovelSoundEffectAsset: vi.fn(),
uploadVisualNovelAsset: vi.fn(),
}));
vi.mock('../../services/assetReadUrlService', () => ({
resolveAssetReadUrl: vi.fn().mockResolvedValue(''),
shouldResolveAssetReadUrl: vi.fn(() => false),
}));
test('visual novel profile tab uses PlatformSubpanel shells', () => {
const { container } = render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
);
const profilePanels = Array.from(
container.querySelectorAll('section.platform-subpanel'),
).filter((panel) => panel.className.includes('rounded-[1.35rem]'));
const workTitlePanel = screen.getByText('作品名称').closest('section');
expect(profilePanels).toHaveLength(2);
for (const panel of profilePanels) {
expect(panel.className).toContain('p-4');
}
expect(workTitlePanel?.className).toContain('platform-subpanel');
expect(workTitlePanel?.className).toContain('rounded-[1.35rem]');
});
test('visual novel profile media previews use PlatformMediaFrame aspects', () => {
const draftWithCover = {
...mockVisualNovelDraft,
coverImageSrc: '/visual-novel/cover.png',
};
const { container } = render(
<VisualNovelResultView draft={draftWithCover} onBack={() => {}} />,
);
const coverImages = Array.from(
container.querySelectorAll('img[src="/visual-novel/cover.png"]'),
);
const mediaFrameClassNames = coverImages.map(
(image) => image.closest('div.relative')?.className ?? '',
);
expect(coverImages).toHaveLength(2);
expect(mediaFrameClassNames).toContainEqual(
expect.stringContaining('aspect-[4/3]'),
);
expect(mediaFrameClassNames).toContainEqual(
expect.stringContaining('aspect-[16/9]'),
);
const assetPreviewFrameClassName = mediaFrameClassNames.find((className) =>
className.includes('aspect-[16/9]'),
);
expect(assetPreviewFrameClassName).toBeTruthy();
expect(assetPreviewFrameClassName).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
});
test('visual novel upload-only asset picker uses PlatformEmptyState chrome', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '封面' }));
const pickerDialog = screen.getByRole('dialog', { name: '封面' });
const uploadOnlyEmptyState =
within(pickerDialog).getByText('选择本地文件上传');
expect(uploadOnlyEmptyState.className).toContain('border-dashed');
expect(uploadOnlyEmptyState.className).toContain('rounded-[1.35rem]');
expect(uploadOnlyEmptyState.className).toContain('min-h-24');
expect(uploadOnlyEmptyState.className).toContain(
'text-[var(--platform-text-base)]',
);
});
test('visual novel entity list items use interactive PlatformSubpanel shells', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
const characterCard = screen.getByRole('button', { name: //u });
expect(characterCard.className).toContain('platform-subpanel');
expect(characterCard.className).toContain('hover:bg-white');
expect(characterCard.className).toContain('disabled:cursor-not-allowed');
expect(characterCard.className).toContain('rounded-[1.25rem]');
});
test('visual novel empty entity list uses PlatformEmptyState shell', async () => {
const user = userEvent.setup();
const emptyCharacterDraft = {
...mockVisualNovelDraft,
characters: [],
};
render(
<VisualNovelResultView draft={emptyCharacterDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
const emptyPanel = screen
.getByText('暂无角色')
.closest('.platform-empty-state');
expect(emptyPanel?.className).toContain('platform-empty-state');
expect(emptyPanel?.className).toContain('bg-white/74');
expect(emptyPanel?.className).toContain('rounded-[1.25rem]');
expect(emptyPanel?.className).toContain('min-h-32');
expect(emptyPanel?.className).toContain(
'text-[var(--platform-text-soft)]',
);
});
test('visual novel result opens complex editors as a dialog', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '林遥' });
expect(within(dialog).getByDisplayValue('林遥')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
});
test('visual novel result exposes test run action with current draft', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onStartTestRun.mock.calls[0]?.[0].workTitle).toBe('雪线电台');
});
test('visual novel result sends edited character draft to save and test run', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const onStartTestRun = vi.fn();
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
/>,
);
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '林遥' });
const nameInput = within(dialog).getByDisplayValue('林遥');
await user.clear(nameInput);
await user.type(nameInput, '林遥改');
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
const saveButtons = screen.getAllByRole('button', { name: '保存草稿' });
await user.click(saveButtons[1]!);
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onSaveDraft.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
expect(onStartTestRun.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
});
test('visual novel result uploads scene and character assets into platform references', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const uploadMock = vi.mocked(
await import('../../services/visual-novel-creation'),
).uploadVisualNovelAsset;
uploadMock.mockResolvedValue({
assetObjectId: 'asset-scene-1',
assetKind: 'scene_image',
objectKey:
'generated-custom-world-scenes/vn-profile/scene-1/background.png',
imageSrc:
'/generated-custom-world-scenes/vn-profile/scene-1/background.png',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '风雪站台' });
const backgroundButtons = within(dialog).getAllByRole('button', {
name: '背景图',
});
await user.click(backgroundButtons[0]!);
const fileInput = within(
screen.getByRole('dialog', { name: '背景图' }),
).getByLabelText('上传背景图文件') as HTMLInputElement;
await user.upload(
fileInput,
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
);
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(onSaveDraft).toHaveBeenCalled();
expect(
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/');
});
test('visual novel result generates scene background from asset picker', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import(
'../../services/visual-novel-creation'
);
const generateImageMock = vi.mocked(
visualNovelCreation.generateVisualNovelImageAsset,
);
generateImageMock.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp',
assetId: 'asset-scene-ai',
model: 'test-image-model',
size: '1280*720',
taskId: 'task-scene-ai',
prompt: '默认图片提示词',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const editorDialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(
within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!,
);
await user.click(
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
name: 'AI生成',
}),
);
await user.click(within(editorDialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(generateImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/vn-profile/scene-ai.webp',
);
});