feat: add Husky hooks and fix provider tutorial UI/logic
- Add Husky pre-commit and pre-push hooks for linting, type checking, and build validation - Fix visual hierarchy bug in the provider onboarding tutorial - Remove feedback modal - Move MinIO bucket creation logic to before app startup - Wire MiniMax audio through voice generation pipeline - Fix scene insertion issues - Fix portal tutorial modal and harden panel variant task flow
This commit is contained in:
169
tests/unit/api-config/provider-card-tutorial-modal.test.ts
Normal file
169
tests/unit/api-config/provider-card-tutorial-modal.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { UseProviderCardStateResult } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
|
||||
import { ProviderCardShell } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
|
||||
import type { ProviderTutorial } from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
|
||||
return {
|
||||
providerKey: 'ark',
|
||||
isPresetProvider: true,
|
||||
showBaseUrlEdit: false,
|
||||
tutorial,
|
||||
groupedModels: {},
|
||||
hasModels: false,
|
||||
isEditing: false,
|
||||
isEditingUrl: false,
|
||||
showKey: false,
|
||||
tempKey: '',
|
||||
tempUrl: '',
|
||||
showTutorial: true,
|
||||
showAddForm: null,
|
||||
newModel: {
|
||||
name: '',
|
||||
modelId: '',
|
||||
enableCustomPricing: false,
|
||||
priceInput: '',
|
||||
priceOutput: '',
|
||||
basePrice: '',
|
||||
optionPricesJson: '',
|
||||
},
|
||||
batchMode: false,
|
||||
editingModelId: null,
|
||||
editModel: {
|
||||
name: '',
|
||||
modelId: '',
|
||||
enableCustomPricing: false,
|
||||
priceInput: '',
|
||||
priceOutput: '',
|
||||
basePrice: '',
|
||||
optionPricesJson: '',
|
||||
},
|
||||
maskedKey: '',
|
||||
isPresetModel: () => false,
|
||||
isDefaultModel: () => false,
|
||||
setShowKey: () => undefined,
|
||||
setShowTutorial: () => undefined,
|
||||
setShowAddForm: () => undefined,
|
||||
setBatchMode: () => undefined,
|
||||
setNewModel: () => undefined,
|
||||
setEditModel: () => undefined,
|
||||
setTempKey: () => undefined,
|
||||
setTempUrl: () => undefined,
|
||||
startEditKey: () => undefined,
|
||||
startEditUrl: () => undefined,
|
||||
handleSaveKey: () => Promise.resolve(),
|
||||
handleCancelEdit: () => undefined,
|
||||
handleSaveUrl: () => undefined,
|
||||
handleCancelUrlEdit: () => undefined,
|
||||
handleEditModel: () => undefined,
|
||||
handleCancelEditModel: () => undefined,
|
||||
handleSaveModel: () => Promise.resolve(),
|
||||
handleAddModel: () => Promise.resolve(),
|
||||
handleCancelAdd: () => undefined,
|
||||
needsCustomPricing: false,
|
||||
keyTestStatus: 'idle',
|
||||
keyTestSteps: [],
|
||||
handleForceSaveKey: () => undefined,
|
||||
handleTestOnly: () => undefined,
|
||||
handleDismissTest: () => undefined,
|
||||
isModelSavePending: false,
|
||||
assistantEnabled: false,
|
||||
isAssistantOpen: false,
|
||||
assistantSavedEvent: null,
|
||||
assistantChat: {
|
||||
messages: [],
|
||||
input: '',
|
||||
status: 'ready',
|
||||
pending: false,
|
||||
error: undefined,
|
||||
setInput: () => undefined,
|
||||
send: async () => undefined,
|
||||
clear: () => undefined,
|
||||
},
|
||||
openAssistant: () => undefined,
|
||||
closeAssistant: () => undefined,
|
||||
handleAssistantSend: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProviderCardShell tutorial modal', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalMocks.currentPortalTarget = null
|
||||
Reflect.deleteProperty(globalThis, 'React')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
})
|
||||
|
||||
it('mounts the tutorial modal 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 tutorial: ProviderTutorial = {
|
||||
providerId: 'ark',
|
||||
steps: [
|
||||
{
|
||||
text: 'ark_step1',
|
||||
url: 'https://example.com/ark-key',
|
||||
},
|
||||
],
|
||||
}
|
||||
const state = createState(tutorial)
|
||||
const t = (key: string): string => {
|
||||
if (key === 'tutorial.button') return '开通教程'
|
||||
if (key === 'tutorial.title') return '开通教程'
|
||||
if (key === 'tutorial.subtitle') return '按照以下步骤完成配置'
|
||||
if (key === 'tutorial.steps.ark_step1') return '进入控制台创建 API Key'
|
||||
if (key === 'tutorial.openLink') return '点击打开'
|
||||
if (key === 'tutorial.close') return '关闭'
|
||||
return key
|
||||
}
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
ProviderCardShell,
|
||||
{
|
||||
provider: {
|
||||
id: 'ark',
|
||||
name: '阿里云百炼',
|
||||
hasApiKey: true,
|
||||
},
|
||||
onDeleteProvider: () => undefined,
|
||||
t,
|
||||
state,
|
||||
},
|
||||
createElement('div', null, 'provider-body'),
|
||||
),
|
||||
)
|
||||
|
||||
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('进入控制台创建 API Key')
|
||||
expect(html).toContain('href="https://example.com/ark-key"')
|
||||
})
|
||||
})
|
||||
72
tests/unit/components/llm-stage-stream-card-error.test.ts
Normal file
72
tests/unit/components/llm-stage-stream-card-error.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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: '该步骤未返回思考过程',
|
||||
},
|
||||
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')
|
||||
})
|
||||
})
|
||||
115
tests/unit/components/navbar-download-logs.test.ts
Normal file
115
tests/unit/components/navbar-download-logs.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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="/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')
|
||||
})
|
||||
})
|
||||
26
tests/unit/novel-promotion/insert-panel-user-input.test.ts
Normal file
26
tests/unit/novel-promotion/insert-panel-user-input.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
|
||||
|
||||
describe('insert panel user input normalization', () => {
|
||||
it('uses localized default instruction when AI analyze sends empty input', () => {
|
||||
expect(resolveInsertPanelUserInput({ userInput: '' }, 'zh')).toBe(
|
||||
'请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
|
||||
)
|
||||
expect(resolveInsertPanelUserInput({ userInput: ' ' }, 'en')).toBe(
|
||||
'Automatically analyze the surrounding panels and insert a naturally connected new panel.',
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers explicit user input over fallback prompt or default', () => {
|
||||
expect(resolveInsertPanelUserInput({
|
||||
userInput: ' 添加一个特写反应镜头 ',
|
||||
prompt: 'unused prompt',
|
||||
}, 'zh')).toBe('添加一个特写反应镜头')
|
||||
})
|
||||
|
||||
it('falls back to prompt when userInput is missing', () => {
|
||||
expect(resolveInsertPanelUserInput({
|
||||
prompt: ' Insert a pause beat between these panels. ',
|
||||
}, 'en')).toBe('Insert a pause beat between these panels.')
|
||||
})
|
||||
})
|
||||
97
tests/unit/storage/bootstrap.test.ts
Normal file
97
tests/unit/storage/bootstrap.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ensureStorageReady } from '@/lib/storage/bootstrap'
|
||||
|
||||
type MockCommand = {
|
||||
readonly type: 'HeadBucketCommand' | 'CreateBucketCommand'
|
||||
readonly input: Record<string, unknown>
|
||||
}
|
||||
|
||||
const {
|
||||
sendMock,
|
||||
s3ClientMock,
|
||||
headBucketCommandMock,
|
||||
createBucketCommandMock,
|
||||
} = vi.hoisted(() => ({
|
||||
sendMock: vi.fn<(command: MockCommand) => Promise<unknown>>(),
|
||||
s3ClientMock: vi.fn(() => ({ send: undefined as unknown })),
|
||||
headBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
|
||||
type: 'HeadBucketCommand',
|
||||
input,
|
||||
})),
|
||||
createBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
|
||||
type: 'CreateBucketCommand',
|
||||
input,
|
||||
})),
|
||||
}))
|
||||
|
||||
s3ClientMock.mockImplementation(() => ({ send: sendMock }))
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => ({
|
||||
S3Client: s3ClientMock,
|
||||
HeadBucketCommand: headBucketCommandMock,
|
||||
CreateBucketCommand: createBucketCommandMock,
|
||||
}))
|
||||
|
||||
describe('storage bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'
|
||||
process.env.MINIO_REGION = 'us-east-1'
|
||||
process.env.MINIO_BUCKET = 'waoowaoo'
|
||||
process.env.MINIO_ACCESS_KEY = 'minioadmin'
|
||||
process.env.MINIO_SECRET_KEY = 'minioadmin'
|
||||
process.env.MINIO_FORCE_PATH_STYLE = 'true'
|
||||
})
|
||||
|
||||
it('skips startup storage initialization when STORAGE_TYPE=local', async () => {
|
||||
await expect(ensureStorageReady({ storageType: 'local' })).resolves.toBe('skipped')
|
||||
expect(s3ClientMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('verifies the MinIO bucket during startup when it already exists', async () => {
|
||||
sendMock.mockResolvedValueOnce({})
|
||||
|
||||
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('existing')
|
||||
|
||||
expect(s3ClientMock).toHaveBeenCalledWith({
|
||||
endpoint: 'http://127.0.0.1:9000',
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
},
|
||||
})
|
||||
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||
expect(createBucketCommandMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates the MinIO bucket during startup when HeadBucket reports it missing', async () => {
|
||||
sendMock
|
||||
.mockRejectedValueOnce(Object.assign(new Error('missing bucket'), {
|
||||
name: 'NotFound',
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
}))
|
||||
.mockResolvedValueOnce({})
|
||||
|
||||
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('created')
|
||||
|
||||
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||
expect(createBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||
expect(sendMock).toHaveBeenNthCalledWith(2, {
|
||||
type: 'CreateBucketCommand',
|
||||
input: { Bucket: 'waoowaoo' },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails fast when MinIO returns a non-bucket-missing error at startup', async () => {
|
||||
const startupError = Object.assign(new Error('MinIO unavailable'), {
|
||||
name: 'TimeoutError',
|
||||
$metadata: { httpStatusCode: 503 },
|
||||
})
|
||||
sendMock.mockRejectedValueOnce(startupError)
|
||||
|
||||
await expect(ensureStorageReady({ storageType: 'minio' })).rejects.toBe(startupError)
|
||||
expect(createBucketCommandMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { USER_FEEDBACK_FORM_URL } from '@/lib/feedback'
|
||||
|
||||
describe('USER_FEEDBACK_FORM_URL', () => {
|
||||
it('should point to the Feishu feedback form', () => {
|
||||
expect(USER_FEEDBACK_FORM_URL).toBe(
|
||||
'https://ox2p5ferjnr.feishu.cn/share/base/form/shrcno200ar2SsTgGiSDYHLmNuc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user