test: complete P4.1 LM Studio coverage and fix auto/wizard handoff
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,53 @@ async function skipOnboarding(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBookWithContent(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill('自动测试书')
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
|
||||||
|
await page.keyboard.press('Control+s')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAutoPanel(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await expect(page.getByTestId('ai-mode-tab-auto')).toBeEnabled({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('ai-mode-tab-auto').click()
|
||||||
|
await expect(page.getByTestId('auto-panel')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function planAutoScenes(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('auto-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
|
||||||
|
await page.getByTestId('auto-plan-scenes').click()
|
||||||
|
await expect(page.getByTestId('auto-start-generate')).toBeVisible({ timeout: 240_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateOneAutoScene(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('auto-start-generate').click()
|
||||||
|
const naming0 = page.getByTestId('naming-option-0')
|
||||||
|
const finishBtn = page.getByTestId('auto-finish-chapter')
|
||||||
|
await expect(naming0.or(finishBtn).or(page.getByTestId('auto-start-generate'))).toBeVisible({
|
||||||
|
timeout: 240_000
|
||||||
|
})
|
||||||
|
if (await naming0.isVisible()) {
|
||||||
|
await naming0.click()
|
||||||
|
await expect(finishBtn.or(page.getByTestId('auto-start-generate'))).toBeVisible({
|
||||||
|
timeout: 240_000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Auto writing', () => {
|
test.describe('Auto writing', () => {
|
||||||
let userDataDir: string
|
let userDataDir: string
|
||||||
|
|
||||||
@@ -49,4 +96,41 @@ test.describe('Auto writing', () => {
|
|||||||
await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 })
|
await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 })
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('E2E-AUTO-01: auto flow creates chapter', async () => {
|
||||||
|
test.setTimeout(300_000)
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openBookWithContent(page)
|
||||||
|
await openAutoPanel(page)
|
||||||
|
await planAutoScenes(page)
|
||||||
|
await generateOneAutoScene(page)
|
||||||
|
await expect(page.getByTestId('auto-finish-chapter')).toBeVisible({ timeout: 30_000 })
|
||||||
|
await page.getByTestId('auto-finish-chapter').click()
|
||||||
|
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-AUTO-02: pause and handoff to interactive', async () => {
|
||||||
|
test.setTimeout(300_000)
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openBookWithContent(page)
|
||||||
|
await openAutoPanel(page)
|
||||||
|
await planAutoScenes(page)
|
||||||
|
await page.getByTestId('auto-start-generate').click()
|
||||||
|
await expect(page.getByTestId('auto-pause')).toBeEnabled({ timeout: 15_000 })
|
||||||
|
await page.waitForTimeout(2_000)
|
||||||
|
await page.getByTestId('auto-pause').click()
|
||||||
|
await expect(page.getByTestId('auto-handoff-interactive')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('auto-handoff-interactive').click()
|
||||||
|
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 15_000 })
|
||||||
|
const acceptBtn = page.getByTestId('interactive-accept-scene')
|
||||||
|
const plotA = page.getByTestId('plot-option-A')
|
||||||
|
const startBtn = page.getByTestId('interactive-start')
|
||||||
|
await expect(acceptBtn.or(plotA).or(startBtn)).toBeVisible({ timeout: 120_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,42 @@ async function skipOnboarding(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBookWithContent(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill('向导测试书')
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
|
||||||
|
await page.keyboard.press('Control+s')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWizardPanel(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('ai-mode-tab-wizard').click()
|
||||||
|
await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmWizardContext(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('wizard-context-edit').click()
|
||||||
|
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('context-tab-chapter').click()
|
||||||
|
await page.locator('[data-testid^="context-item-"]').first().check()
|
||||||
|
await page.getByTestId('context-refresh').click()
|
||||||
|
await page.getByTestId('context-save').click()
|
||||||
|
await expect(page.getByTestId('wizard-preview')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Wizard writing', () => {
|
test.describe('Wizard writing', () => {
|
||||||
let userDataDir: string
|
let userDataDir: string
|
||||||
|
|
||||||
@@ -49,4 +85,42 @@ test.describe('Wizard writing', () => {
|
|||||||
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 })
|
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 })
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('E2E-WIZ-01: wizard flow reaches interactive panel', async () => {
|
||||||
|
test.setTimeout(600_000)
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openBookWithContent(page)
|
||||||
|
await openWizardPanel(page)
|
||||||
|
|
||||||
|
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
|
||||||
|
await page.getByTestId('wizard-rhythm-mixed').click()
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
|
||||||
|
await confirmWizardContext(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '刷新预览' }).click()
|
||||||
|
await expect(page.getByTestId('wizard-preview')).not.toContainText('…', { timeout: 240_000 })
|
||||||
|
|
||||||
|
await page.getByTestId('wizard-start-generate').click()
|
||||||
|
await expect(page.getByTestId('ai-mode-tab-interactive')).toHaveClass(/active/, { timeout: 360_000 })
|
||||||
|
|
||||||
|
const acceptBtn = page.getByTestId('interactive-accept-scene')
|
||||||
|
const plotA = page.getByTestId('plot-option-A')
|
||||||
|
const naming0 = page.getByTestId('naming-option-0')
|
||||||
|
await expect(acceptBtn.or(plotA).or(naming0)).toBeVisible({ timeout: 120_000 })
|
||||||
|
|
||||||
|
if (await naming0.isVisible()) {
|
||||||
|
await naming0.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect(acceptBtn.or(plotA)).toBeVisible({ timeout: 120_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
data-testid="auto-pause"
|
data-testid="auto-pause"
|
||||||
disabled={disabled || streaming}
|
disabled={disabled}
|
||||||
onClick={() => void pause(bookId)}
|
onClick={() => void pause(bookId)}
|
||||||
>
|
>
|
||||||
{t('auto.pause')}
|
{t('auto.pause')}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
|
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||||
|
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||||
@@ -29,7 +31,11 @@ export function RightPanel(): React.JSX.Element {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (tab.id === 'ai') {
|
if (tab.id === 'ai') {
|
||||||
const bookId = useBookStore.getState().currentBookId
|
const bookId = useBookStore.getState().currentBookId
|
||||||
if (bookId) void useInteractiveStore.getState().checkGate(bookId)
|
if (bookId) {
|
||||||
|
void useInteractiveStore.getState().checkGate(bookId)
|
||||||
|
void useAutoStore.getState().checkGate(bookId)
|
||||||
|
void useWizardStore.getState().checkGate(bookId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setRightPanel(tab.id)
|
setRightPanel(tab.id)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useBookStore } from '@renderer/stores/useBookStore'
|
|||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
|
|
||||||
interface AutoState {
|
interface AutoState {
|
||||||
@@ -126,11 +127,17 @@ export const useAutoStore = create<AutoState>((set, get) => ({
|
|||||||
|
|
||||||
handoffInteractive: async (bookId) => {
|
handoffInteractive: async (bookId) => {
|
||||||
const flowId = get().flow?.id
|
const flowId = get().flow?.id
|
||||||
if (!flowId) return
|
const sessionId = useAiStore.getState().activeSessionId
|
||||||
|
if (!flowId || !sessionId) return
|
||||||
const flow = await ipcCall(() => window.electronAPI.auto.handoffInteractive(bookId, flowId))
|
const flow = await ipcCall(() => window.electronAPI.auto.handoffInteractive(bookId, flowId))
|
||||||
set({ flow })
|
set({ flow, streaming: false, streamBuffer: '' })
|
||||||
useAiStore.getState().setWritingMode('interactive')
|
useAiStore.getState().setWritingMode('interactive')
|
||||||
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
||||||
|
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
|
||||||
|
const interactiveFlow = useInteractiveStore.getState().flow
|
||||||
|
if (interactiveFlow?.step === 'plot_suggest') {
|
||||||
|
await useInteractiveStore.getState().suggestPlots(bookId)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
finishChapter: async (bookId, title) => {
|
finishChapter: async (bookId, title) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
|
|
||||||
@@ -108,13 +109,19 @@ export const useWizardStore = create<WizardState>((set, get) => ({
|
|||||||
|
|
||||||
startGenerate: async (bookId) => {
|
startGenerate: async (bookId) => {
|
||||||
const flowId = get().flow?.id
|
const flowId = get().flow?.id
|
||||||
if (!flowId) return
|
const sessionId = useAiStore.getState().activeSessionId
|
||||||
|
if (!flowId || !sessionId) return
|
||||||
set({ streaming: true, streamBuffer: '' })
|
set({ streaming: true, streamBuffer: '' })
|
||||||
try {
|
try {
|
||||||
const flow = await ipcCall(() => window.electronAPI.wizard.startGenerate(bookId, flowId))
|
const flow = await ipcCall(() => window.electronAPI.wizard.startGenerate(bookId, flowId))
|
||||||
set({ flow })
|
set({ flow })
|
||||||
useAiStore.getState().setWritingMode('interactive')
|
useAiStore.getState().setWritingMode('interactive')
|
||||||
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
||||||
|
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
|
||||||
|
const interactiveFlow = useInteractiveStore.getState().flow
|
||||||
|
if (interactiveFlow?.step === 'plot_suggest') {
|
||||||
|
await useInteractiveStore.getState().suggestPlots(bookId)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
set({ streaming: false, streamBuffer: '' })
|
set({ streaming: false, streamBuffer: '' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||||
|
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
|
||||||
|
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
|
||||||
|
|
||||||
|
describe('AutoWritingService generate', () => {
|
||||||
|
const service = (db: DatabaseSync) =>
|
||||||
|
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
|
||||||
|
async function planFirstScene(db: DatabaseSync, svc: AutoWritingService, flowId: string): Promise<void> {
|
||||||
|
await svc.planScenes(flowId, CONTEXT)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('IT-AUTO-02: generateNext writes non-empty scene', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
|
const svc = service(db)
|
||||||
|
const flow = svc.start(session.id)
|
||||||
|
svc.setGoal(flow.id, {
|
||||||
|
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
|
||||||
|
wordMin: 800,
|
||||||
|
wordMax: 1500
|
||||||
|
})
|
||||||
|
await planFirstScene(db, svc, flow.id)
|
||||||
|
|
||||||
|
let afterGen = await svc.generateNext(flow.id, CONTEXT, () => {})
|
||||||
|
if (afterGen.step === 'naming_pause' && afterGen.namingPending) {
|
||||||
|
afterGen = await svc.resolveNaming(
|
||||||
|
flow.id,
|
||||||
|
afterGen.namingPending.options[0]!,
|
||||||
|
CONTEXT,
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenes = new InteractiveRepository(db).listScenes(flow.id)
|
||||||
|
expect(scenes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(scenes[0]!.contentHtml.replace(/<[^>]+>/g, '').trim().length).toBeGreaterThan(20)
|
||||||
|
db.close()
|
||||||
|
}, 240_000)
|
||||||
|
|
||||||
|
it('IT-AUTO-03: pause and resume update step', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
|
const svc = service(db)
|
||||||
|
const flow = svc.start(session.id)
|
||||||
|
svc.setGoal(flow.id, {
|
||||||
|
chapterGoal: '林远通过入门考核',
|
||||||
|
wordMin: 800,
|
||||||
|
wordMax: 1500
|
||||||
|
})
|
||||||
|
await planFirstScene(db, svc, flow.id)
|
||||||
|
|
||||||
|
const paused = svc.pause(flow.id)
|
||||||
|
expect(paused.step).toBe('paused')
|
||||||
|
|
||||||
|
const resumed = svc.resume(flow.id)
|
||||||
|
expect(resumed.step).toBe('auto_generate')
|
||||||
|
db.close()
|
||||||
|
}, 240_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||||
|
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
|
||||||
|
|
||||||
|
describe('AutoWritingService.planScenes', () => {
|
||||||
|
const service = (db: DatabaseSync) =>
|
||||||
|
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
|
||||||
|
it('IT-AUTO-01: planScenes returns >=2 scenes', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
|
const svc = service(db)
|
||||||
|
const flow = svc.start(session.id)
|
||||||
|
svc.setGoal(flow.id, {
|
||||||
|
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
|
||||||
|
wordMin: 800,
|
||||||
|
wordMax: 1500
|
||||||
|
})
|
||||||
|
const plan = await svc.planScenes(flow.id, CONTEXT)
|
||||||
|
expect(plan.scenes.length).toBeGreaterThanOrEqual(2)
|
||||||
|
db.close()
|
||||||
|
}, 360_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||||
|
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
|
||||||
|
import { WizardWritingService } from '../../src/main/services/wizard-writing.service'
|
||||||
|
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { DEFAULT_AI_CONFIG, type AutoWritingConfig, type InteractiveFlow } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 300_000, maxTokens: 4096 }
|
||||||
|
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
|
||||||
|
|
||||||
|
describe('WizardWritingService.startGenerate', () => {
|
||||||
|
const wizard = (db: DatabaseSync) =>
|
||||||
|
new WizardWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
const auto = (db: DatabaseSync) =>
|
||||||
|
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
|
||||||
|
async function setupWizardFlow(svc: WizardWritingService, sessionId: string): Promise<string> {
|
||||||
|
let flow = svc.start(sessionId)
|
||||||
|
flow = svc.setGoal(flow.id, {
|
||||||
|
chapterGoal: '林远通过入门考核,展现天赋',
|
||||||
|
wordMin: 600,
|
||||||
|
wordMax: 1200
|
||||||
|
})
|
||||||
|
flow = svc.setRhythm(flow.id, 'tense')
|
||||||
|
flow = svc.setPov(flow.id)
|
||||||
|
flow = svc.confirmContext(
|
||||||
|
flow.id,
|
||||||
|
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||||
|
CONTEXT
|
||||||
|
)
|
||||||
|
return flow.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continueAfterPartialGenerate(
|
||||||
|
wizSvc: WizardWritingService,
|
||||||
|
autoSvc: AutoWritingService,
|
||||||
|
repo: InteractiveRepository,
|
||||||
|
flowId: string,
|
||||||
|
initial: InteractiveFlow
|
||||||
|
): Promise<InteractiveFlow> {
|
||||||
|
let flow = initial
|
||||||
|
for (let guard = 0; guard < 8 && flow.flowMode !== 'interactive'; guard++) {
|
||||||
|
if (flow.step === 'naming_pause' && flow.namingPending) {
|
||||||
|
flow = await autoSvc.resolveNaming(
|
||||||
|
flowId,
|
||||||
|
flow.namingPending.options[0]!,
|
||||||
|
CONTEXT,
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const config = repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||||
|
const planLen = config.scenePlan?.length ?? 0
|
||||||
|
const idx = config.currentPlanIndex ?? 0
|
||||||
|
if (idx < planLen) {
|
||||||
|
flow = await autoSvc.generateNext(flowId, CONTEXT, () => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (flow.scenes.length > 0) {
|
||||||
|
return wizSvc.handoffToInteractive(flowId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return flow
|
||||||
|
}
|
||||||
|
|
||||||
|
it('IT-WIZ-02: startGenerate handoffs to interactive with scenes', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const repo = new InteractiveRepository(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
|
const wizSvc = wizard(db)
|
||||||
|
const autoSvc = auto(db)
|
||||||
|
const flowId = await setupWizardFlow(wizSvc, session.id)
|
||||||
|
|
||||||
|
const initial = await wizSvc.startGenerate(flowId, CONTEXT, () => {})
|
||||||
|
const flow =
|
||||||
|
initial.flowMode === 'interactive'
|
||||||
|
? initial
|
||||||
|
: await continueAfterPartialGenerate(wizSvc, autoSvc, repo, flowId, initial)
|
||||||
|
|
||||||
|
expect(flow.flowMode).toBe('interactive')
|
||||||
|
expect(flow.scenes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
db.close()
|
||||||
|
}, 600_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||||
|
import { WizardWritingService } from '../../src/main/services/wizard-writing.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
|
||||||
|
|
||||||
|
describe('WizardWritingService.buildPreview', () => {
|
||||||
|
const service = (db: DatabaseSync) =>
|
||||||
|
new WizardWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
|
||||||
|
it('IT-WIZ-01: buildPreview returns non-empty HTML', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
|
const svc = service(db)
|
||||||
|
let flow = svc.start(session.id)
|
||||||
|
flow = svc.setGoal(flow.id, {
|
||||||
|
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
|
||||||
|
wordMin: 800,
|
||||||
|
wordMax: 1500
|
||||||
|
})
|
||||||
|
flow = svc.setRhythm(flow.id, 'mixed')
|
||||||
|
flow = svc.setPov(flow.id)
|
||||||
|
flow = svc.confirmContext(
|
||||||
|
flow.id,
|
||||||
|
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||||
|
CONTEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
|
||||||
|
expect(preview).toMatch(/<p>/)
|
||||||
|
expect(preview.replace(/<[^>]+>/g, '').trim().length).toBeGreaterThan(10)
|
||||||
|
db.close()
|
||||||
|
}, 240_000)
|
||||||
|
})
|
||||||
+2
-1
@@ -5,7 +5,8 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
testTimeout: 240_000
|
testTimeout: 240_000,
|
||||||
|
fileParallelism: false
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user