first commit
This commit is contained in:
@@ -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('"characters"')
|
||||
expect(html).toContain('"appearance": "朝服形象"')
|
||||
expect(html).toContain('"slot": "皇宫正中龙椅前方台阶下的位置"')
|
||||
})
|
||||
})
|
||||
@@ -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('智能修改')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user