视觉小说实体列表空态复用 PlatformEmptyState 角色素材工作室局部按钮改为委托 PlatformActionButton RPG大编辑器局部按钮改为委托 PlatformActionButton 更新 PlatformUiKit 收口文档与团队决策记录
312 lines
10 KiB
TypeScript
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',
|
|
);
|
|
});
|