style: polish UI and improve UX
This commit is contained in:
158
tests/unit/components/asset-grid.test.ts
Normal file
158
tests/unit/components/asset-grid.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import { AssetGrid } from '@/app/[locale]/workspace/asset-hub/components/AssetGrid'
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useState: <T,>(initialState: T | (() => T)) => {
|
||||
const resolvedInitialState = typeof initialState === 'function'
|
||||
? (initialState as () => T)()
|
||||
: initialState
|
||||
|
||||
if (resolvedInitialState === 'all') {
|
||||
return actual.useState('location' as T)
|
||||
}
|
||||
|
||||
return actual.useState(resolvedInitialState)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/CharacterCard', () => ({
|
||||
CharacterCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/LocationCard', () => ({
|
||||
LocationCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceCard', () => ({
|
||||
VoiceCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetHub: {
|
||||
allAssets: '所有资产',
|
||||
characters: '角色',
|
||||
locations: '场景',
|
||||
props: '道具',
|
||||
voices: '音色',
|
||||
addAsset: '新建资产',
|
||||
addCharacter: '新建角色',
|
||||
addLocation: '新建场景',
|
||||
addProp: '新建道具',
|
||||
addVoice: '新建音色',
|
||||
downloadAll: '打包下载',
|
||||
downloadAllTitle: '下载全部图片资产',
|
||||
downloading: '打包中...',
|
||||
emptyState: '暂无资产',
|
||||
emptyStateHint: '点击上方按钮添加角色或场景',
|
||||
filteredEmptyHint: '点击新建资产添加资产',
|
||||
pagination: {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const renderWithIntl = (node: ReactElement) => {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AssetGrid', () => {
|
||||
it('空状态下使用与资产库一致的 compact 分段控件,并在中间显示新建资产按钮', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetGrid, {
|
||||
assets: [],
|
||||
loading: false,
|
||||
onAddCharacter: () => undefined,
|
||||
onAddLocation: () => undefined,
|
||||
onAddProp: () => undefined,
|
||||
onAddVoice: () => undefined,
|
||||
onDownloadAll: () => undefined,
|
||||
isDownloading: false,
|
||||
selectedFolderId: null,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('inline-block max-w-full min-w-max')
|
||||
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||
expect(html).toContain('justify-center')
|
||||
expect(html).toContain('>新建资产<')
|
||||
})
|
||||
|
||||
it('当前筛选分类没有资产时显示添加提示文案', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetGrid, {
|
||||
assets: [
|
||||
{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
scope: 'project',
|
||||
name: '角色A',
|
||||
folderId: null,
|
||||
capabilities: {
|
||||
canGenerate: true,
|
||||
canSelectRender: false,
|
||||
canRevertRender: false,
|
||||
canModifyRender: false,
|
||||
canUploadRender: false,
|
||||
canBindVoice: false,
|
||||
canCopyFromGlobal: false,
|
||||
},
|
||||
taskRefs: [],
|
||||
taskState: { isRunning: false, lastError: null },
|
||||
variants: [],
|
||||
introduction: null,
|
||||
profileData: null,
|
||||
profileConfirmed: null,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: { isRunning: false, lastError: null },
|
||||
voice: {
|
||||
voiceType: null,
|
||||
voiceId: null,
|
||||
customVoiceUrl: null,
|
||||
media: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
onAddCharacter: () => undefined,
|
||||
onAddLocation: () => undefined,
|
||||
onAddProp: () => undefined,
|
||||
onAddVoice: () => undefined,
|
||||
onDownloadAll: () => undefined,
|
||||
isDownloading: false,
|
||||
selectedFolderId: null,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('点击新建资产添加资产')
|
||||
})
|
||||
})
|
||||
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
@@ -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('压迫氛围')
|
||||
})
|
||||
})
|
||||
51
tests/unit/components/story-input-composer.test.ts
Normal file
51
tests/unit/components/story-input-composer.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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('开始创作')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user