first commit

This commit is contained in:
wsq
2026-05-13 21:58:19 +08:00
commit 0167c66cb7
1475 changed files with 233414 additions and 0 deletions
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { copyPreviewJsonText } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModalPreviewPane'
describe('AIDataModalPreviewPane copy helper', () => {
it('falls back to execCommand when clipboard api rejects', async () => {
const writeText = vi.fn(async () => {
throw new Error('clipboard denied')
})
const appendChild = vi.fn()
const removeChild = vi.fn()
const select = vi.fn()
const textarea = {
value: '',
style: {} as Record<string, string>,
select,
}
vi.stubGlobal('navigator', { clipboard: { writeText } })
vi.stubGlobal('document', {
body: {
appendChild,
removeChild,
},
createElement: vi.fn(() => textarea),
execCommand: vi.fn(() => true),
})
await expect(copyPreviewJsonText('{"a":1}')).resolves.toBeUndefined()
expect(writeText).toHaveBeenCalledWith('{"a":1}')
expect(appendChild).toHaveBeenCalledWith(textarea)
expect(select).toHaveBeenCalled()
})
})
@@ -0,0 +1,48 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import AIDataModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModal'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}))
describe('AIDataModal', () => {
it('在查看数据预览中展示角色完整数据与 slot', () => {
Reflect.set(globalThis, 'React', React)
vi.stubGlobal('document', { body: {} })
const html = renderToStaticMarkup(
createElement(AIDataModal, {
isOpen: true,
onClose: () => undefined,
panelNumber: 1,
shotType: 'medium shot',
cameraMove: 'static',
description: '皇帝立于大殿中央',
location: '皇宫大殿',
characters: [
{
name: '皇帝',
appearance: '朝服形象',
slot: '皇宫正中龙椅前方台阶下的位置',
},
],
videoPrompt: 'dramatic court scene',
photographyRules: null,
actingNotes: null,
videoRatio: '16:9',
onSave: () => undefined,
}),
)
expect(html).toContain('&quot;characters&quot;')
expect(html).toContain('&quot;appearance&quot;: &quot;朝服形象&quot;')
expect(html).toContain('&quot;slot&quot;: &quot;皇宫正中龙椅前方台阶下的位置&quot;')
})
})
@@ -0,0 +1,155 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
vi.mock('@/components/ui/icons', () => ({
AppIcon: (props: { className?: string }) => createElement('span', { className: props.className }),
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => createElement('span', null, 'loading'),
}))
vi.mock('@/lib/task/presentation', () => ({
resolveTaskPresentationState: () => null,
}))
vi.mock('@/lib/query/hooks', () => ({
useUpdateCharacterName: () => ({ isPending: false, mutateAsync: vi.fn() }),
useUpdateProjectCharacterName: () => ({ isPending: false, mutateAsync: vi.fn() }),
useUpdateCharacterAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
useUpdateProjectAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
useUpdateProjectCharacterIntroduction: () => ({ mutateAsync: vi.fn() }),
useAiModifyCharacterDescription: () => ({ mutateAsync: vi.fn() }),
useAiModifyProjectAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
useUpdateLocationName: () => ({ isPending: false, mutateAsync: vi.fn() }),
useUpdateProjectLocationName: () => ({ isPending: false, mutateAsync: vi.fn() }),
useUpdateLocationSummary: () => ({ mutateAsync: vi.fn() }),
useUpdateProjectLocationDescription: () => ({ mutateAsync: vi.fn() }),
useAiModifyLocationDescription: () => ({ mutateAsync: vi.fn() }),
useAiModifyProjectLocationDescription: () => ({ mutateAsync: vi.fn() }),
useAiModifyPropDescription: () => ({ mutateAsync: vi.fn() }),
useAiModifyProjectPropDescription: () => ({ mutateAsync: vi.fn() }),
useAssetActions: () => ({
update: vi.fn(),
updateVariant: vi.fn(),
generate: vi.fn(),
}),
}))
const messages = {
assets: {
common: {
cancel: '取消',
},
character: {
name: '角色名',
appearance: '形象',
},
location: {
name: '场景名',
description: '场景描述',
},
prop: {
name: '道具名',
summary: '简要说明',
summaryPlaceholder: '一句话说明这是什么道具,不写剧情用途',
description: '图片描述',
descriptionPlaceholder: '只写道具本体的材质、颜色、结构和装饰细节',
},
modal: {
editCharacter: '编辑角色',
editLocation: '编辑场景',
editProp: '编辑道具',
namePlaceholder: '输入名称',
appearancePrompt: '形象描述提示词',
descPlaceholder: '输入描述',
modifyDescription: 'AI修改描述',
modifyPlaceholder: '改成夜晚',
modifyPlaceholderCharacter: '改成黑色西装',
modifyPlaceholderProp: '改成磨砂银质',
saveName: '保存名字',
saveOnly: '仅保存',
saveAndGenerate: '保存并生成',
introduction: '角色介绍',
introductionPlaceholder: '输入角色介绍',
introductionTip: '介绍角色在故事中的身份',
},
smartImport: {
preview: {
saving: '保存中',
},
},
errors: {
saveFailed: '保存失败',
failed: '失败',
},
},
} as const
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
locale: string
messages: AbstractIntlMessages
timeZone: string
children?: React.ReactNode
}>
function renderWithMessages(node: React.ReactElement) {
return renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
node,
),
)
}
describe('asset edit modal AI layout', () => {
it('renders character AI modify action inside the description composer instead of a standalone smart-modify card', async () => {
Reflect.set(globalThis, 'React', React)
const { CharacterEditModal } = await import('@/components/shared/assets/CharacterEditModal')
const html = renderWithMessages(
createElement(CharacterEditModal, {
mode: 'project',
characterId: 'character-1',
characterName: '沈烬',
description: '冷峻禁欲的男性角色形象描述',
appearanceId: 'appearance-1',
onClose: () => undefined,
onSave: () => undefined,
}),
)
expect(html).toContain('AI修改描述')
expect(html).not.toContain('改成黑色西装')
expect(html).not.toContain('智能修改')
})
it('renders prop AI modify action with the prop-specific placeholder', async () => {
Reflect.set(globalThis, 'React', React)
const { PropEditModal } = await import('@/components/shared/assets/PropEditModal')
const html = renderWithMessages(
createElement(PropEditModal, {
mode: 'project',
propId: 'prop-1',
propName: '遗物匕首',
summary: '旧时代留下的金属短刃',
description: '青铜短刃,刃面斑驳,手柄有细密雕纹',
variantId: 'prop-variant-1',
projectId: 'project-1',
onClose: () => undefined,
}),
)
expect(html).toContain('AI修改描述')
expect(html).not.toContain('改成磨砂银质')
expect(html).not.toContain('智能修改')
})
})
+158
View File
@@ -0,0 +1,158 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { AssetGrid } from '@/app/[locale]/workspace/asset-hub/components/AssetGrid'
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useState: <T,>(initialState: T | (() => T)) => {
const resolvedInitialState = typeof initialState === 'function'
? (initialState as () => T)()
: initialState
if (resolvedInitialState === 'all') {
return actual.useState('location' as T)
}
return actual.useState(resolvedInitialState)
},
}
})
vi.mock('@/app/[locale]/workspace/asset-hub/components/CharacterCard', () => ({
CharacterCard: () => null,
}))
vi.mock('@/app/[locale]/workspace/asset-hub/components/LocationCard', () => ({
LocationCard: () => null,
}))
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceCard', () => ({
VoiceCard: () => null,
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => null,
}))
const messages = {
assetHub: {
allAssets: '所有资产',
characters: '角色',
locations: '场景',
props: '道具',
voices: '音色',
addAsset: '新建资产',
addCharacter: '新建角色',
addLocation: '新建场景',
addProp: '新建道具',
addVoice: '新建音色',
downloadAll: '打包下载',
downloadAllTitle: '下载全部图片资产',
downloading: '打包中...',
emptyState: '暂无资产',
emptyStateHint: '点击上方按钮添加角色或场景',
filteredEmptyHint: '点击新建资产添加资产',
pagination: {
previous: '上一页',
next: '下一页',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('AssetGrid', () => {
it('空状态下使用与资产库一致的 compact 分段控件,并在中间显示新建资产按钮', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetGrid, {
assets: [],
loading: false,
onAddCharacter: () => undefined,
onAddLocation: () => undefined,
onAddProp: () => undefined,
onAddVoice: () => undefined,
onDownloadAll: () => undefined,
isDownloading: false,
selectedFolderId: null,
}),
)
expect(html).toContain('inline-block max-w-full min-w-max')
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
expect(html).toContain('justify-center')
expect(html).toContain('>新建资产<')
})
it('当前筛选分类没有资产时显示添加提示文案', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetGrid, {
assets: [
{
id: 'character-1',
kind: 'character',
family: 'visual',
scope: 'project',
name: '角色A',
folderId: null,
capabilities: {
canGenerate: true,
canSelectRender: false,
canRevertRender: false,
canModifyRender: false,
canUploadRender: false,
canBindVoice: false,
canCopyFromGlobal: false,
},
taskRefs: [],
taskState: { isRunning: false, lastError: null },
variants: [],
introduction: null,
profileData: null,
profileConfirmed: null,
profileTaskRefs: [],
profileTaskState: { isRunning: false, lastError: null },
voice: {
voiceType: null,
voiceId: null,
customVoiceUrl: null,
media: null,
},
},
],
loading: false,
onAddCharacter: () => undefined,
onAddLocation: () => undefined,
onAddProp: () => undefined,
onAddVoice: () => undefined,
onDownloadAll: () => undefined,
isDownloading: false,
selectedFolderId: null,
}),
)
expect(html).toContain('点击新建资产添加资产')
})
})
@@ -0,0 +1,243 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
const idleMutation = {
isPending: false,
mutate: vi.fn(),
}
vi.mock('@/lib/query/mutations', () => ({
useGenerateCharacterImage: () => idleMutation,
useSelectCharacterImage: () => idleMutation,
useUndoCharacterImage: () => idleMutation,
useUploadCharacterImage: () => idleMutation,
useDeleteCharacter: () => idleMutation,
useDeleteCharacterAppearance: () => idleMutation,
useUploadCharacterVoice: () => idleMutation,
useGenerateLocationImage: () => idleMutation,
useSelectLocationImage: () => idleMutation,
useUndoLocationImage: () => idleMutation,
useUploadLocationImage: () => idleMutation,
useDeleteLocation: () => idleMutation,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: (props: { className?: string; name?: string }) =>
createElement('span', { className: props.className, 'data-icon': props.name }),
}))
vi.mock('@/components/task/TaskStatusOverlay', () => ({
default: () => createElement('div', null, 'overlay'),
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => createElement('span', null, 'inline'),
}))
vi.mock('@/components/media/MediaImageWithLoading', () => ({
MediaImageWithLoading: (props: { containerClassName?: string; className?: string }) =>
createElement('div', {
className: [props.containerClassName, props.className].filter(Boolean).join(' '),
}),
}))
vi.mock('@/components/image-generation/ImageGenerationInlineCountButton', () => ({
default: () => createElement('button', null, 'count'),
}))
vi.mock('@/lib/task/presentation', () => ({
resolveTaskPresentationState: () => null,
}))
vi.mock('@/lib/image-generation/use-image-generation-count', () => ({
useImageGenerationCount: () => ({
count: 1,
setCount: vi.fn(),
}),
}))
vi.mock('@/lib/image-generation/count', () => ({
getImageGenerationCountOptions: () => [{ value: 1, label: '1' }],
}))
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceSettings', () => ({
default: () => createElement('div', null, 'voice-settings'),
}))
const messages = {
assetHub: {
generateFailed: '生成失败',
selectFailed: '选择失败',
uploadFailed: '上传失败',
confirmDeleteLocation: '确认删除场景',
confirmDeleteProp: '确认删除道具',
confirmDeleteCharacter: '确认删除角色',
cancel: '取消',
delete: '删除',
propLabel: '道具',
locationLabel: '场景',
},
assets: {
image: {
generateCountPrefix: '生成',
generateCountSuffix: '张',
generating: '生成中',
generatingPlaceholder: '正在生成',
regenerateStuck: '重新生成',
regenCountPrefix: '重生成',
undo: '撤回',
upload: '上传',
uploadReplace: '替换',
edit: '编辑',
selectCount: '选择数量',
confirmOption: '确认选择',
optionNumber: '方案 {number}',
},
common: {
generateFailed: '生成失败',
},
location: {
regenerateImage: '重生成场景',
edit: '编辑场景',
delete: '删除场景',
},
prop: {
regenerateImage: '重生成道具',
edit: '编辑道具',
delete: '删除道具',
},
character: {
deleteWhole: '删除整个角色',
primary: '主形象',
secondary: '子形象',
delete: '删除角色',
deleteOptions: '删除选项',
},
video: {
panelCard: {
editPrompt: '编辑',
},
},
},
} as const
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
locale: string
messages: AbstractIntlMessages
timeZone: string
children?: React.ReactNode
}>
function renderWithIntl(node: React.ReactElement) {
return renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
node,
),
)
}
describe('asset hub card aspect ratio', () => {
it('keeps prop cards at the same 3:2 ratio as character assets while generation is running', async () => {
Reflect.set(globalThis, 'React', React)
const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard')
const html = renderWithIntl(
createElement(LocationCard, {
location: {
id: 'prop-1',
name: '鼠标',
summary: '电脑鼠标',
folderId: null,
images: [
{
id: 'prop-image-1',
imageIndex: 0,
description: null,
imageUrl: null,
previousImageUrl: null,
isSelected: false,
imageTaskRunning: true,
},
],
},
assetType: 'prop',
}),
)
expect(html).toContain('aspect-[3/2]')
expect(html).toContain('data-icon="image"')
expect(html).not.toContain('min-h-[100px]')
})
it('keeps location cards square while generation is running', async () => {
Reflect.set(globalThis, 'React', React)
const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard')
const html = renderWithIntl(
createElement(LocationCard, {
location: {
id: 'location-1',
name: '餐厅',
summary: '极简餐厅',
folderId: null,
images: [
{
id: 'location-image-1',
imageIndex: 0,
description: null,
imageUrl: null,
previousImageUrl: null,
isSelected: false,
imageTaskRunning: true,
},
],
},
assetType: 'location',
}),
)
expect(html).toContain('aspect-square')
expect(html).toContain('data-icon="image"')
expect(html).not.toContain('min-h-[100px]')
})
it('keeps character cards at the fixed 3:2 ratio while generation is running', async () => {
Reflect.set(globalThis, 'React', React)
const { CharacterCard } = await import('@/app/[locale]/workspace/asset-hub/components/CharacterCard')
const html = renderWithIntl(
createElement(CharacterCard, {
character: {
id: 'character-1',
name: '沈烬',
folderId: null,
customVoiceUrl: null,
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: '默认形象',
description: null,
imageUrl: null,
imageUrls: [],
selectedIndex: null,
previousImageUrl: null,
previousImageUrls: [],
imageTaskRunning: true,
},
],
},
}),
)
expect(html).toContain('aspect-[3/2]')
expect(html).not.toContain('min-h-[100px]')
})
})
@@ -0,0 +1,80 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import AssetToolbar from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar'
vi.mock('@/lib/query/hooks', () => ({
useProjectAssets: vi.fn(() => ({ data: { characters: [], locations: [], props: [] } })),
useProjectData: vi.fn(() => ({ data: { name: '项目A' } })),
}))
const messages = {
assets: {
common: {
refresh: '刷新',
},
filterBar: {
allEpisodes: '全部集数',
},
toolbar: {
assetManagement: '资产管理',
assetCount: '共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)',
globalAnalyze: '全局分析',
globalAnalyzeHint: '分析所有资产',
downloadAll: '下载全部',
generateAll: '生成全部图片',
regenerateAll: '重新生成全部',
regenerateAllHint: '重新生成所有图片',
},
assetLibrary: {
downloadEmpty: '没有可下载图片',
downloadFailed: '下载失败',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('AssetToolbar', () => {
it('删除批量生成与刷新按钮 -> 仅保留全局分析和下载入口', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetToolbar, {
projectId: 'project-1',
totalAssets: 24,
totalAppearances: 11,
totalLocations: 13,
totalProps: 0,
isBatchSubmitting: false,
isAnalyzingAssets: false,
isGlobalAnalyzing: false,
onGlobalAnalyze: () => undefined,
episodeId: null,
onEpisodeChange: () => undefined,
episodes: [],
}),
)
expect(html).toContain('全局分析')
expect(html).toContain('title="下载全部"')
expect(html).not.toContain('生成全部图片')
expect(html).not.toContain('重新生成全部')
expect(html).not.toContain('>刷新<')
})
})
@@ -0,0 +1,46 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { CapsuleNav, EpisodeSelector } from '@/components/ui/CapsuleNav'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('CapsuleNav layering', () => {
it('keeps fixed workspace navigation below modal overlays', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement('div', null,
createElement(CapsuleNav, {
items: [
{ id: 'config', icon: 'sparkles', label: '配置', status: 'active' as const },
],
activeId: 'config',
onItemClick: () => undefined,
projectId: 'project-1',
}),
createElement(EpisodeSelector, {
episodes: [
{ id: 'episode-1', title: '剧集 1' },
],
currentId: 'episode-1',
onSelect: () => undefined,
projectName: '项目 A',
}),
),
)
expect(html).toContain('fixed top-20 left-1/2 -translate-x-1/2 z-40')
expect(html).toContain('fixed top-20 left-6 z-40')
expect(html).not.toContain('z-50 animate-fadeInDown')
expect(html).not.toContain('z-[60]')
})
})
@@ -0,0 +1,70 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
vi.mock('@/components/ui/icons', () => ({
AppIcon: () => createElement('span', null),
}))
vi.mock('@/components/task/TaskStatusOverlay', () => ({
default: () => createElement('div', null, 'overlay'),
}))
vi.mock('@/components/media/MediaImageWithLoading', () => ({
MediaImageWithLoading: (props: { containerClassName?: string; className?: string }) =>
createElement('div', { className: [props.containerClassName, props.className].filter(Boolean).join(' ') }),
}))
const messages = {
assets: {
common: {
generateFailed: '生成失败',
},
image: {
optionNumber: '方案 {number}',
},
},
} as const
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
locale: string
messages: AbstractIntlMessages
timeZone: string
children?: React.ReactNode
}>
describe('CharacterCardGallery aspect ratio', () => {
it('renders the single-image slot at a fixed 3:2 ratio', async () => {
Reflect.set(globalThis, 'React', React)
const { default: CharacterCardGallery } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/character-card/CharacterCardGallery')
const html = renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
createElement(CharacterCardGallery, {
mode: 'single',
characterName: '沈烬',
changeReason: '默认形象',
aspectClassName: 'aspect-[3/2]',
currentImageUrl: null,
selectedIndex: null,
hasMultipleImages: false,
isAppearanceTaskRunning: true,
displayTaskPresentation: null,
onImageClick: () => undefined,
overlayActions: null,
}),
),
)
expect(html).toContain('aspect-[3/2]')
})
})
@@ -0,0 +1,116 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { CharacterCreationModal } from '@/components/shared/assets/CharacterCreationModal'
vi.mock('@/lib/query/hooks', () => ({
useProjectAssets: vi.fn(() => ({ data: { characters: [] } })),
}))
vi.mock('@/components/shared/assets/character-creation/hooks/useCharacterCreationSubmit', () => ({
useCharacterCreationSubmit: vi.fn(() => ({
isSubmitting: false,
isAiDesigning: false,
isExtracting: false,
characterGenerationCount: 3,
setCharacterGenerationCount: vi.fn(),
referenceCharacterGenerationCount: 3,
setReferenceCharacterGenerationCount: vi.fn(),
handleExtractDescription: vi.fn(),
handleCreateWithReference: vi.fn(),
handleAiDesign: vi.fn(),
handleSubmit: vi.fn(),
handleSubmitAndGenerate: vi.fn(),
})),
}))
const messages = {
assetModal: {
character: {
title: '新建角色',
name: '角色名称',
namePlaceholder: '请输入角色名称',
modeReference: '参考图模式',
modeDescription: '描述模式',
uploadReference: '上传参考图',
pasteHint: 'Ctrl+V 粘贴',
generationMode: '生成方式',
directGenerate: '直接生成',
extractPrompt: '反推提示词',
extractFirst: '先提取描述',
description: '角色描述',
descPlaceholder: '请输入角色外貌描述...',
isSubAppearance: '这是一个子形象',
isSubAppearanceHint: '为已有角色添加新的形象状态',
selectMainCharacter: '选择主角色',
selectCharacterPlaceholder: '请选择角色...',
appearancesCount: '{count} 个形象',
changeReason: '形象变化原因',
changeReasonPlaceholder: '例如',
useReferenceGeneratePrefix: '使用参考图生成',
generateCountSuffix: '张图片',
selectReferenceGenerateCount: '选择参考图生成数量',
},
artStyle: { title: '画面风格' },
aiDesign: {
title: 'AI 设计',
placeholder: '描述你想要的角色特征...',
generating: '设计中...',
generate: '生成',
},
common: {
creating: '创建中...',
cancel: '取消',
adding: '添加中...',
add: '添加',
addOnly: '仅添加角色',
addOnlyToAssetHub: '仅添加人物到资产库',
addAndGeneratePrefix: '添加并生成',
generateCountSuffix: '张图片',
selectGenerateCount: '选择生成数量',
optional: '(可选)',
},
errors: {
uploadFailed: '上传失败',
extractDescriptionFailed: '提取描述失败',
createFailed: '创建失败',
aiDesignFailed: 'AI 设计失败',
addSubAppearanceFailed: '添加子形象失败',
insufficientBalance: '账户余额不足',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('CharacterCreationModal', () => {
it('renders add-only and add-and-generate actions in the fixed footer', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(CharacterCreationModal, {
mode: 'asset-hub',
onClose: () => undefined,
onSuccess: () => undefined,
}),
)
expect(html).toContain('仅添加人物到资产库')
expect(html).toContain('添加并生成')
expect(html).toContain('取消')
})
})
@@ -0,0 +1,143 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import CharacterSection from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection'
const useProjectAssetsMock = vi.hoisted(() => vi.fn())
const characterCardMock = vi.hoisted(() => vi.fn((_props: unknown) => null))
vi.mock('@/lib/query/hooks/useProjectAssets', () => ({
useProjectAssets: (projectId: string | null) => useProjectAssetsMock(projectId),
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard', () => ({
__esModule: true,
default: (props: unknown) => characterCardMock(props),
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterProfileCard', () => ({
__esModule: true,
default: () => null,
}))
vi.mock('@/types/character-profile', () => ({
parseProfileData: () => null,
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
__esModule: true,
default: () => null,
}))
vi.mock('@/lib/task/presentation', () => ({
resolveTaskPresentationState: () => null,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: (props: { name?: string; className?: string }) =>
createElement('span', { 'data-icon': props.name, className: props.className }),
}))
const messages = {
assets: {
stage: {
characterAssets: '角色资产',
counts: '{characterCount} 个角色,{appearanceCount} 个形象',
pendingProfilesBanner: '待确认角色',
pendingProfilesHint: '确认角色设定',
confirmAll: '全部确认',
},
character: {
add: '新建角色',
assetCount: '{count} 个形象',
copyFromGlobal: '从资产中心导入',
delete: '删除角色',
},
},
} as const
function renderWithIntl(node: ReactElement) {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('CharacterSection actions', () => {
it('renders import and delete actions stacked vertically with the import icon', () => {
Reflect.set(globalThis, 'React', React)
useProjectAssetsMock.mockReturnValue({
data: {
characters: [
{
id: 'character-1',
name: '西装男',
introduction: null,
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: '初始形象',
imageUrl: null,
imageUrls: [],
selectedIndex: null,
},
],
},
],
},
})
const html = renderWithIntl(
createElement(CharacterSection, {
projectId: 'project-1',
activeTaskKeys: new Set<string>(),
onClearTaskKey: () => undefined,
onRegisterTransientTaskKey: () => undefined,
isAnalyzingAssets: false,
onAddCharacter: () => undefined,
onDeleteCharacter: () => undefined,
onDeleteAppearance: () => undefined,
onEditAppearance: () => undefined,
handleGenerateImage: async () => undefined,
onSelectImage: () => undefined,
onConfirmSelection: () => undefined,
onRegenerateSingle: async () => undefined,
onRegenerateGroup: async () => undefined,
onUndo: () => undefined,
onImageClick: () => undefined,
onImageEdit: () => undefined,
onVoiceChange: () => undefined,
onVoiceDesign: () => undefined,
onVoiceSelectFromHub: () => undefined,
onCopyFromGlobal: () => undefined,
getAppearances: (character) => character.appearances,
unconfirmedCharacters: [],
isConfirmingCharacter: () => false,
deletingCharacterId: null,
batchConfirming: false,
batchConfirmingState: null,
onBatchConfirm: () => undefined,
onEditProfile: () => undefined,
onConfirmProfile: () => undefined,
onUseExistingProfile: () => undefined,
onDeleteProfile: () => undefined,
}),
)
expect(html).toContain('从资产中心导入')
expect(html).toContain('删除角色')
expect(html).toContain('data-icon="arrowDownCircle"')
expect(html).toContain('flex flex-col items-end gap-1.5')
})
})
@@ -0,0 +1,150 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
const useQueryMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: unknown) => useQueryMock(options),
}))
vi.mock('@/components/ui/ImagePreviewModal', () => ({
__esModule: true,
default: () => null,
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
__esModule: true,
default: () => null,
}))
vi.mock('@/lib/task/presentation', () => ({
resolveTaskPresentationState: () => null,
}))
vi.mock('@/components/media/MediaImageWithLoading', () => ({
MediaImageWithLoading: (props: { src: string; alt: string; className?: string; containerClassName?: string }) =>
createElement('img', {
src: props.src,
alt: props.alt,
className: [props.className, props.containerClassName].filter(Boolean).join(' '),
}),
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: (props: { name?: string; className?: string }) =>
createElement('span', { 'data-icon': props.name, className: props.className }),
}))
const messages = {
assetPicker: {
selectCharacter: '从资产中心选择角色',
selectLocation: '从资产中心选择场景',
selectProp: '从资产中心选择道具',
selectVoice: '从资产中心选择音色',
searchPlaceholder: '搜索资产名称或文件夹...',
noAssets: '资产中心暂无资产',
createInAssetHub: '请先在资产中心创建角色/场景/音色',
noSearchResults: '未找到匹配的资产',
appearances: '个形象',
images: '张图片',
cancel: '取消',
confirmCopy: '确认导入',
},
} as const
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
locale: string
messages: AbstractIntlMessages
timeZone: string
children?: React.ReactNode
}>
describe('GlobalAssetPicker preview mapping', () => {
it('renders the real character preview image at 3:2 without the appearance count line', async () => {
Reflect.set(globalThis, 'React', React)
useQueryMock.mockReset()
useQueryMock.mockImplementation((options: { enabled?: boolean }) => ({
data: options.enabled ? [{
id: 'character-1',
kind: 'character',
family: 'visual',
scope: 'global',
name: '西装男',
folderId: null,
capabilities: {
canGenerate: true,
canSelectRender: true,
canRevertRender: true,
canModifyRender: true,
canUploadRender: true,
canBindVoice: true,
canCopyFromGlobal: false,
},
taskRefs: [],
taskState: { isRunning: false, lastError: null },
introduction: null,
profileData: null,
profileConfirmed: null,
profileTaskRefs: [],
profileTaskState: { isRunning: false, lastError: null },
voice: {
voiceType: null,
voiceId: null,
customVoiceUrl: null,
media: null,
},
variants: [{
id: 'variant-1',
index: 0,
label: '默认形象',
description: '黑西装',
selectionState: { selectedRenderIndex: 0 },
taskRefs: [],
taskState: { isRunning: false, lastError: null },
renders: [{
id: 'render-1',
index: 0,
imageUrl: 'https://example.com/character.png',
media: null,
isSelected: true,
previousImageUrl: null,
previousMedia: null,
taskRefs: [],
taskState: { isRunning: false, lastError: null },
}],
}],
}] : [],
isFetching: false,
refetch: vi.fn(),
}))
const { default: GlobalAssetPicker } = await import('@/components/shared/assets/GlobalAssetPicker')
const html = renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
createElement(GlobalAssetPicker, {
isOpen: true,
onClose: () => undefined,
onSelect: () => undefined,
type: 'character',
}),
),
)
expect(html).toContain('src="https://example.com/character.png"')
expect(html).toContain('aspect-[3/2]')
expect(html).toContain('object-contain')
expect(html).not.toContain('data-icon="userAlt"')
expect(html).not.toContain('border-b')
expect(html).not.toContain('个形象')
})
})
@@ -0,0 +1,77 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
describe('ImageGenerationInlineCountButton', () => {
it('keeps the select enabled when only the action is disabled', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '生成'),
suffix: createElement('span', null, '张图片'),
value: 3,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
actionDisabled: true,
selectDisabled: false,
ariaLabel: '选择生成数量',
}),
)
expect(html).toContain('role="button"')
expect(html).toContain('aria-disabled="true"')
expect(html).not.toContain('<select disabled=""')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('inline-flex shrink-0 items-center whitespace-nowrap leading-none')
})
it('renders the count control as a rounded inline pill with the chevron inside it', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: createElement('span', null, '张'),
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
ariaLabel: '选择重新生成数量',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('张')
expect(html).toContain('whitespace-nowrap')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('right-2')
expect(html).toContain('hover:bg-white/16')
})
it('can render a regenerate action without exposing the count selector', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: null,
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
showCountControl: false,
ariaLabel: '重新生成当前图片',
className: 'inline-flex h-6 items-center justify-center rounded-md px-1.5',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('type="button"')
expect(html).not.toContain('<select')
expect(html).not.toContain('rounded-full bg-white/12')
})
})
@@ -0,0 +1,98 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import LLMStageStreamCard from '@/components/llm-console/LLMStageStreamCard'
const messages = {
progress: {
status: {
completed: '已完成',
failed: '失败',
processing: '进行中',
queued: '排队中',
pending: '未开始',
},
stageCard: {
stage: '阶段',
realtimeStream: '实时流',
currentStage: '当前阶段',
outputTitle: 'AI 实时输出 · {stage}',
waitingModelOutput: '等待模型输出...',
reasoningNotProvided: '该步骤未返回思考过程',
},
streamStep: {
analyzeProps: '道具分析',
},
runtime: {
llm: {
processing: '模型处理中...',
},
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('LLMStageStreamCard error rendering', () => {
it('renders the error without any feedback action entry', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LLMStageStreamCard, {
title: '内容到剧本',
stages: [{
id: 'story_to_script',
title: '内容到剧本',
status: 'failed',
progress: 0,
}],
activeStageId: 'story_to_script',
outputText: '',
errorMessage: 'Failed to fetch',
}),
)
expect(html).toContain('Failed to fetch')
expect(html).not.toContain('复制错误详情')
expect(html).not.toContain('打开问题反馈表单')
expect(html).not.toContain('Copy error detail')
expect(html).not.toContain('Open feedback form')
})
it('resolves analyze props progress keys without missing message errors', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LLMStageStreamCard, {
title: 'progress.streamStep.analyzeProps',
stages: [{
id: 'analyze_props',
title: 'progress.streamStep.analyzeProps',
subtitle: 'progress.streamStep.analyzeProps',
status: 'processing',
progress: 35,
}],
activeStageId: 'analyze_props',
activeMessage: 'progress.streamStep.analyzeProps',
outputText: '',
}),
)
expect(html).toContain('道具分析')
expect(html).not.toContain('progress.streamStep.analyzeProps')
expect(html).not.toContain('MISSING_MESSAGE')
})
})
@@ -0,0 +1,187 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { AI_EDIT_BUTTON_CLASS } from '@/components/ui/ai-edit-style'
const locationImageListMock = vi.hoisted(() => vi.fn((props: { overlayActions?: React.ReactNode }) => createElement('div', null, props.overlayActions ?? null)))
const uploadMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
vi.mock('@/lib/query/mutations', () => ({
useUploadProjectLocationImage: () => uploadMutationMock,
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardHeader', () => ({
default: () => createElement('div', null),
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions', () => ({
default: () => createElement('div', null),
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationImageList', () => ({
default: locationImageListMock,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: () => createElement('span', null),
}))
vi.mock('@/components/ui/icons/AISparklesIcon', () => ({
default: (props: { className?: string }) => createElement('svg', { className: props.className, 'data-icon': 'ai-sparkles' }),
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => createElement('span', null),
}))
vi.mock('@/components/image-generation/ImageGenerationInlineCountButton', () => ({
default: () => createElement('button', null),
}))
vi.mock('@/lib/image-generation/use-image-generation-count', () => ({
useImageGenerationCount: () => ({
count: 1,
setCount: vi.fn(),
}),
}))
vi.mock('@/lib/image-generation/count', () => ({
getImageGenerationCountOptions: () => [{ value: 1, label: '1' }],
}))
vi.mock('@/lib/task/presentation', () => ({
resolveTaskPresentationState: () => null,
}))
const messages = {
assets: {
image: {
upload: '上传图片',
uploadReplace: '上传替换图片',
edit: '编辑图片',
undo: '撤回',
regenerateStuck: '重新生成',
},
location: {
regenerateImage: '重新生成场景',
edit: '编辑场景',
delete: '删除场景',
},
prop: {
regenerateImage: '重新生成道具',
edit: '编辑道具',
delete: '删除道具',
},
},
} as const
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
locale: string
messages: AbstractIntlMessages
timeZone: string
children?: React.ReactNode
}>
describe('LocationCard AI edit button', () => {
it('uses the shared AI edit button style in single-image mode', async () => {
locationImageListMock.mockClear()
Reflect.set(globalThis, 'React', React)
const { default: LocationCard } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard')
const html = renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
createElement(LocationCard, {
location: {
id: 'prop-1',
name: '银质餐具',
summary: '银质西式餐具套装',
selectedImageId: 'prop-image-1',
images: [
{
id: 'prop-image-1',
imageIndex: 0,
description: '银质餐具套装,包含刀叉与汤匙,金属光泽冷白',
imageUrl: 'https://example.com/prop.png',
previousImageUrl: null,
previousDescription: null,
isSelected: true,
},
],
},
assetType: 'prop',
onEdit: () => undefined,
onDelete: () => undefined,
onRegenerate: () => undefined,
onGenerate: () => undefined,
onImageClick: () => undefined,
onImageEdit: () => undefined,
projectId: 'project-1',
}),
),
)
expect(html).toContain('data-icon=\"ai-sparkles\"')
for (const token of AI_EDIT_BUTTON_CLASS.split(' ')) {
expect(html).toContain(token)
}
const firstCall = locationImageListMock.mock.calls[0]?.[0] as { aspectClassName?: string } | undefined
expect(firstCall?.aspectClassName).toBe('aspect-[3/2]')
})
it('passes a square image slot to project location cards', async () => {
locationImageListMock.mockClear()
Reflect.set(globalThis, 'React', React)
const { default: LocationCard } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard')
renderToStaticMarkup(
createElement(
TestIntlProvider,
{
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
},
createElement(LocationCard, {
location: {
id: 'location-1',
name: '餐厅',
summary: '极简餐厅',
selectedImageId: 'location-image-1',
images: [
{
id: 'location-image-1',
imageIndex: 0,
description: '极简餐厅室内空间',
imageUrl: 'https://example.com/location.png',
previousImageUrl: null,
previousDescription: null,
isSelected: true,
},
],
},
assetType: 'location',
onEdit: () => undefined,
onDelete: () => undefined,
onRegenerate: () => undefined,
onGenerate: () => undefined,
onImageClick: () => undefined,
onImageEdit: () => undefined,
projectId: 'project-1',
}),
),
)
const firstCall = locationImageListMock.mock.calls[0]?.[0] as { aspectClassName?: string } | undefined
expect(firstCall?.aspectClassName).toBe('aspect-square')
})
})
@@ -0,0 +1,81 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { LocationCreationModal } from '@/components/shared/assets/LocationCreationModal'
vi.mock('@/lib/query/hooks', () => ({
useAiCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useAiDesignLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useCreateAssetHubLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useGenerateLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
useCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useGenerateProjectLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
}))
const messages = {
assetModal: {
location: {
title: '新建场景',
name: '场景名称',
namePlaceholder: '请输入场景名称',
description: '场景描述',
descPlaceholder: '请输入场景描述...',
},
artStyle: { title: '画面风格' },
aiDesign: {
title: 'AI 设计',
placeholderLocation: '描述场景氛围和环境...',
generating: '设计中...',
generate: '生成',
tip: '输入简单描述,AI 帮你生成详细设定',
},
common: {
cancel: '取消',
addOnlyLocation: '仅添加场景',
addOnlyToAssetHubLocation: '仅添加场景到资产库',
addAndGeneratePrefix: '添加并生成',
generateCountSuffix: '张图片',
selectGenerateCount: '选择生成数量',
optional: '(可选)',
},
errors: {
createFailed: '创建失败',
aiDesignFailed: 'AI 设计失败',
insufficientBalance: '账户余额不足',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('LocationCreationModal', () => {
it('renders add-only and add-and-generate actions in the fixed footer', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LocationCreationModal, {
mode: 'asset-hub',
onClose: () => undefined,
onSuccess: () => undefined,
}),
)
expect(html).toContain('仅添加场景到资产库')
expect(html).toContain('添加并生成')
expect(html).toContain('取消')
})
})
@@ -0,0 +1,115 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import LocationSection from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection'
const locationCardMock = vi.hoisted(() => vi.fn((_props: unknown) => null))
const useProjectAssetsMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/query/hooks/useProjectAssets', () => ({
useProjectAssets: (projectId: string | null) => useProjectAssetsMock(projectId),
}))
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard', () => ({
default: (props: unknown) => locationCardMock(props),
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: () => null,
}))
const messages = {
assets: {
stage: {
locationAssets: '场景资产',
locationCounts: '{count} 个场景',
propAssets: '道具资产',
propCounts: '{count} 个道具',
},
location: {
add: '新建场景',
},
prop: {
add: '新建道具',
},
},
} as const
function renderWithIntl(node: ReactElement) {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('LocationSection prop confirm wiring', () => {
it('passes the confirm-selection callback through to prop cards', () => {
Reflect.set(globalThis, 'React', React)
locationCardMock.mockClear()
useProjectAssetsMock.mockReturnValue({
data: {
characters: [],
locations: [],
props: [{
id: 'prop-1',
name: '青铜匕首',
summary: '古旧短刃',
selectedImageId: 'prop-image-2',
images: [
{
id: 'prop-image-1',
imageIndex: 0,
description: '候选 1',
imageUrl: 'https://example.com/prop-1.png',
isSelected: false,
},
{
id: 'prop-image-2',
imageIndex: 1,
description: '候选 2',
imageUrl: 'https://example.com/prop-2.png',
isSelected: true,
},
],
}],
},
})
renderWithIntl(
createElement(LocationSection, {
projectId: 'project-1',
assetType: 'prop',
activeTaskKeys: new Set<string>(),
onClearTaskKey: () => undefined,
onRegisterTransientTaskKey: () => undefined,
onAddLocation: () => undefined,
onDeleteLocation: () => undefined,
onEditLocation: () => undefined,
handleGenerateImage: async () => undefined,
onSelectImage: () => undefined,
onConfirmSelection: () => undefined,
onRegenerateSingle: async () => undefined,
onRegenerateGroup: async () => undefined,
onUndo: () => undefined,
onImageClick: () => undefined,
onImageEdit: () => undefined,
onCopyFromGlobal: () => undefined,
filterIds: null,
}),
)
const firstCall = locationCardMock.mock.calls[0]?.[0] as { onConfirmSelection?: () => void } | undefined
expect(firstCall).toBeDefined()
expect(typeof firstCall?.onConfirmSelection).toBe('function')
})
})
@@ -0,0 +1,72 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('LongTextDetectionPrompt', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('renders through document.body at modal layer without the removed gradient border wrapper', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
Reflect.set(globalThis, 'document', fakeDocument)
portalMocks.currentPortalTarget = fakeDocument.body
const html = renderToStaticMarkup(
createElement(LongTextDetectionPrompt, {
open: true,
copy: {
title: '建议使用智能分集',
description: '检测到文本较长',
strongRecommend: '建议拆分',
smartSplitLabel: '智能分集',
smartSplitBadge: '推荐',
continueLabel: '仍然单集创作',
continueHint: '单集模式',
},
onClose: () => undefined,
onSmartSplit: () => undefined,
onContinue: () => undefined,
}),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('z-[120]')
expect(html).toContain('border-[var(--glass-stroke-base)]')
expect(html).not.toContain('p-[1.5px]')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { lockModalPageScroll } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/modal-scroll-lock'
describe('modal scroll lock', () => {
it('locks page scroll while modal is open and restores previous styles on cleanup', () => {
const doc = {
body: {
style: {
overflow: 'auto',
},
},
documentElement: {
style: {
overflow: 'scroll',
},
},
}
const restore = lockModalPageScroll(doc)
expect(doc.body.style.overflow).toBe('hidden')
expect(doc.documentElement.style.overflow).toBe('hidden')
restore()
expect(doc.body.style.overflow).toBe('auto')
expect(doc.documentElement.style.overflow).toBe('scroll')
})
})
@@ -0,0 +1,116 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import Navbar from '@/components/Navbar'
const useSessionMock = vi.fn()
vi.mock('next-auth/react', () => ({
useSession: () => useSessionMock(),
}))
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt: string } & Record<string, unknown>) => createElement('img', { alt, ...props }),
}))
vi.mock('@/components/LanguageSwitcher', () => ({
default: () => createElement('div', null, 'LanguageSwitcher'),
}))
vi.mock('@/hooks/common/useGithubReleaseUpdate', () => ({
useGithubReleaseUpdate: () => ({
currentVersion: '0.3.0',
update: null,
shouldPulse: false,
showModal: false,
openModal: () => undefined,
dismissCurrentUpdate: () => undefined,
checkNow: async () => undefined,
}),
}))
vi.mock('@/i18n/navigation', () => ({
Link: ({
href,
children,
...props
}: {
href: string | { pathname: string }
children: React.ReactNode
} & Record<string, unknown>) => {
const resolvedHref = typeof href === 'string' ? href : href.pathname
return createElement('a', { href: resolvedHref, ...props }, children)
},
}))
const messages = {
nav: {
workspace: '工作区',
assetHub: '资产中心',
profile: '设置中心',
downloadLogs: '下载日志',
signin: '登录',
signup: '注册',
},
common: {
appName: 'waoowaoo',
betaVersion: 'Beta v{version}',
updateNotice: {
openDialog: '打开更新弹窗',
updateTag: '更新',
checkUpdate: '检查更新',
upToDate: '已是最新版本',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('Navbar download logs entry', () => {
beforeEach(() => {
useSessionMock.mockReset()
})
it('renders the download logs entry on the far-right action group for signed-in users', () => {
Reflect.set(globalThis, 'React', React)
useSessionMock.mockReturnValue({
data: { user: { name: 'Earth' } },
status: 'authenticated',
})
const html = renderWithIntl(createElement(Navbar))
expect(html).toContain('下载日志')
expect(html).toContain('href="/home"')
expect(html).toContain('href="/api/admin/download-logs"')
expect(html).toContain('download=""')
})
it('does not render the download logs entry for signed-out users', () => {
Reflect.set(globalThis, 'React', React)
useSessionMock.mockReturnValue({
data: null,
status: 'unauthenticated',
})
const html = renderWithIntl(createElement(Navbar))
expect(html).not.toContain('下载日志')
expect(html).not.toContain('/api/admin/download-logs')
})
})
@@ -0,0 +1,107 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useState: <T,>(initialState: T | (() => T)) => {
const resolvedInitialState = typeof initialState === 'function'
? (initialState as () => T)()
: initialState
if (resolvedInitialState === false) {
return actual.useState(true as T)
}
return actual.useState(resolvedInitialState)
},
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('RatioStyleSelectors', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('renders ratio, style, and style preset dropdown panels through a portal to document.body', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
portalMocks.currentPortalTarget = fakeDocument.body
Reflect.set(globalThis, 'document', fakeDocument)
const html = renderToStaticMarkup(
createElement('div', null,
createElement(RatioSelector, {
value: '9:16',
onChange: () => undefined,
options: [
{ value: '9:16', label: '9:16' },
{ value: '16:9', label: '16:9' },
],
}),
createElement(StyleSelector, {
value: 'realistic',
onChange: () => undefined,
options: [
{ value: 'realistic', label: '真人风格' },
{ value: 'american-comic', label: '美漫风格' },
],
}),
createElement(StylePresetSelector, {
value: 'horror-suspense',
onChange: () => undefined,
options: [
{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' },
{ value: 'dark-noir', label: '暗黑黑色', description: '冷峻低照' },
],
}),
),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(3)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(portalMocks.createPortalMock.mock.calls[1]?.[1]).toBe(fakeDocument.body)
expect(portalMocks.createPortalMock.mock.calls[2]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('data-icon="sparklesAlt"')
expect(html).toContain('data-icon="clapperboard"')
expect(html).toContain('真人风格')
expect(html).toContain('16:9')
expect(html).toContain('恐怖悬疑')
expect(html).toContain('压迫氛围')
})
})
@@ -0,0 +1,29 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
describe('SegmentedControl', () => {
it('compact 布局 -> 输出左对齐的非拉伸结构', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(SegmentedControl, {
options: [
{ value: 'all', label: '全部 (24)' },
{ value: 'character', label: '角色 (11)' },
{ value: 'location', label: '场景 (13)' },
{ value: 'prop', label: '道具 (0)' },
],
value: 'all',
onChange: () => undefined,
layout: 'compact',
}),
)
expect(html).toContain('inline-block max-w-full')
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
expect(html).not.toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
})
})
@@ -0,0 +1,78 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
RatioSelector: ({
getUsage: _getUsage,
...props
}: Record<string, unknown> & { getUsage?: unknown }) => createElement('div', props, 'RatioSelector'),
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
StylePresetSelector: (props: Record<string, unknown>) => createElement('div', props, 'StylePresetSelector'),
}))
describe('StoryInputComposer', () => {
it('renders a shared composer shell with configurable textarea rows and shared controls', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(StoryInputComposer, {
value: '测试内容',
onValueChange: () => undefined,
placeholder: '请输入内容',
minRows: 8,
videoRatio: '9:16',
onVideoRatioChange: () => undefined,
ratioOptions: [{ value: '9:16', label: '9:16' }],
artStyle: 'realistic',
onArtStyleChange: () => undefined,
styleOptions: [{ value: 'realistic', label: '真人风格' }],
stylePresetValue: 'horror-suspense',
onStylePresetChange: () => undefined,
stylePresetOptions: [{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' }],
topRight: createElement('span', null, '字数:4'),
footer: createElement('p', null, '当前配置'),
secondaryActions: createElement('button', { type: 'button' }, 'AI 帮我写'),
primaryAction: createElement('button', { type: 'button' }, '开始创作'),
}),
)
expect(html).toContain('rows="8"')
expect(html).toContain('RatioSelector')
expect(html).toContain('StyleSelector')
expect(html).toContain('StylePresetSelector')
expect(html).toContain('字数:4')
expect(html).toContain('当前配置')
expect(html).toContain('AI 帮我写')
expect(html).toContain('开始创作')
})
it('hides the style preset selector when no preset is enabled', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(StoryInputComposer, {
value: '测试内容',
onValueChange: () => undefined,
placeholder: '请输入内容',
minRows: 8,
videoRatio: '9:16',
onVideoRatioChange: () => undefined,
ratioOptions: [{ value: '9:16', label: '9:16' }],
artStyle: 'realistic',
onArtStyleChange: () => undefined,
styleOptions: [{ value: 'realistic', label: '真人风格' }],
stylePresetValue: '',
onStylePresetChange: () => undefined,
stylePresetOptions: [],
primaryAction: createElement('button', { type: 'button' }, '开始创作'),
}),
)
expect(html).toContain('RatioSelector')
expect(html).toContain('StyleSelector')
expect(html).not.toContain('StylePresetSelector')
})
})
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_VOICE_SCHEME_COUNT,
MAX_VOICE_SCHEME_COUNT,
MIN_VOICE_SCHEME_COUNT,
generateVoiceDesignOptions,
normalizeVoiceSchemeCount,
} from '@/components/voice/voice-design-shared'
describe('voice-design-shared', () => {
it('clamps scheme count into the supported range', () => {
expect(normalizeVoiceSchemeCount(undefined)).toBe(DEFAULT_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount('not-a-number')).toBe(DEFAULT_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount(0)).toBe(MIN_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount(99)).toBe(MAX_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount('5')).toBe(5)
})
it('generates the requested number of voice options with default preview text fallback', async () => {
const onDesignVoice = vi
.fn<(_: {
voicePrompt: string
previewText: string
preferredName: string
language: 'zh'
}) => Promise<{ voiceId: string; audioBase64: string }>>()
.mockResolvedValueOnce({ voiceId: 'voice-1', audioBase64: 'audio-1' })
.mockResolvedValueOnce({ voiceId: 'voice-2', audioBase64: 'audio-2' })
.mockResolvedValueOnce({ voiceId: 'voice-3', audioBase64: 'audio-3' })
.mockResolvedValueOnce({ voiceId: 'voice-4', audioBase64: 'audio-4' })
const result = await generateVoiceDesignOptions({
count: '4',
voicePrompt: ' 温柔女声 ',
previewText: ' ',
defaultPreviewText: '默认试听文案',
onDesignVoice,
createPreferredName: (index) => `preferred-${index + 1}`,
})
expect(result).toEqual([
{ voiceId: 'voice-1', audioBase64: 'audio-1', audioUrl: 'data:audio/wav;base64,audio-1' },
{ voiceId: 'voice-2', audioBase64: 'audio-2', audioUrl: 'data:audio/wav;base64,audio-2' },
{ voiceId: 'voice-3', audioBase64: 'audio-3', audioUrl: 'data:audio/wav;base64,audio-3' },
{ voiceId: 'voice-4', audioBase64: 'audio-4', audioUrl: 'data:audio/wav;base64,audio-4' },
])
expect(onDesignVoice.mock.calls).toEqual([
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-1', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-2', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-3', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-4', language: 'zh' }],
])
})
it('fails explicitly when a designed voice is missing voiceId', async () => {
const onDesignVoice = vi.fn(async () => ({ voiceId: '', audioBase64: 'audio-only' }))
await expect(
generateVoiceDesignOptions({
count: 1,
voicePrompt: '旁白',
previewText: '测试',
defaultPreviewText: '默认试听文案',
onDesignVoice,
}),
).rejects.toThrow('VOICE_DESIGN_INVALID_RESPONSE: missing voiceId')
})
})
@@ -0,0 +1,64 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import WorkspaceRunStreamConsoles from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/llm-console/LLMStageStreamCard', () => ({
__esModule: true,
default: ({ title }: { title: string }) => createElement('section', null, `LLMStageStreamCard:${title}`),
}))
function createStreamState(overrides?: Partial<React.ComponentProps<typeof WorkspaceRunStreamConsoles>['storyToScriptStream']>) {
return {
status: 'running' as const,
isVisible: true,
isRecoveredRunning: true,
stages: [],
selectedStep: null,
activeStepId: null,
outputText: '',
activeMessage: '',
overallProgress: 0,
isRunning: false,
errorMessage: '',
stop: () => undefined,
reset: () => undefined,
selectStep: () => undefined,
retryStep: async () => ({
runId: 'run-1',
status: 'running',
summary: null,
payload: null,
errorMessage: '',
}),
...overrides,
}
}
describe('WorkspaceRunStreamConsoles', () => {
it('shows fallback running console when a recovered run has no stages yet', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(WorkspaceRunStreamConsoles, {
storyToScriptStream: createStreamState(),
scriptToStoryboardStream: createStreamState({
status: 'idle',
isVisible: false,
isRecoveredRunning: false,
}),
storyToScriptConsoleMinimized: false,
scriptToStoryboardConsoleMinimized: true,
onStoryToScriptMinimizedChange: () => undefined,
onScriptToStoryboardMinimizedChange: () => undefined,
}),
)
expect(html).toContain('LLMStageStreamCard:runConsole.storyToScript')
})
})