feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
34
tests/unit/components/ai-data-modal-preview-pane.test.ts
Normal file
34
tests/unit/components/ai-data-modal-preview-pane.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
48
tests/unit/components/ai-data-modal.test.ts
Normal file
48
tests/unit/components/ai-data-modal.test.ts
Normal file
@@ -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": "皇宫正中龙椅前方台阶下的位置"')
|
||||
})
|
||||
})
|
||||
46
tests/unit/components/capsule-nav-layering.test.ts
Normal file
46
tests/unit/components/capsule-nav-layering.test.ts
Normal file
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,56 @@ describe('ImageGenerationInlineCountButton', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('role="button"')
|
||||
expect(html).toContain('aria-disabled="true"')
|
||||
expect(html).toContain('opacity-60 cursor-not-allowed')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
72
tests/unit/components/long-text-detection-prompt.test.ts
Normal file
72
tests/unit/components/long-text-detection-prompt.test.ts
Normal file
@@ -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]')
|
||||
})
|
||||
})
|
||||
29
tests/unit/components/modal-scroll-lock.test.ts
Normal file
29
tests/unit/components/modal-scroll-lock.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
64
tests/unit/components/workspace-run-stream-consoles.test.ts
Normal file
64
tests/unit/components/workspace-run-stream-consoles.test.ts
Normal file
@@ -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