feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -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('取消')
})
})

View File

@@ -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 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('aria-disabled="true"')
expect(html).toContain('opacity-60 cursor-not-allowed')
expect(html).not.toContain('<select disabled=""')
})
})

View File

@@ -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('取消')
})
})

View File

@@ -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')
})
})