feat: ship v0.4.0 with interactive writing mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest'
|
||||
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 }
|
||||
|
||||
describe('AiClientService', () => {
|
||||
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
|
||||
const client = new AiClientService(() => AI_TEST_CONFIG)
|
||||
|
||||
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
|
||||
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('InteractiveWritingService.finishChapter', () => {
|
||||
let db: SqliteDb
|
||||
let service: InteractiveWritingService
|
||||
let volId: string
|
||||
let flowId: string
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const session = new AiSessionRepository(db).create('测试', 'test-model')
|
||||
const flow = new InteractiveRepository(db).create(session.id)
|
||||
flowId = flow.id
|
||||
const repo = new InteractiveRepository(db)
|
||||
repo.addScene(flowId, '<p>场景一</p>', 'A')
|
||||
repo.addScene(flowId, '<p>场景二</p>', 'B')
|
||||
service = new InteractiveWritingService(db, {
|
||||
chat: async () => ''
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => db.close())
|
||||
|
||||
it('IT-INT-04: merges scenes into new interactive chapter', () => {
|
||||
const { chapterId } = service.finishChapter(flowId, volId, '交互初稿')
|
||||
const ch = new ChapterRepository(db).get(chapterId)!
|
||||
expect(ch.origin).toBe('interactive')
|
||||
expect(ch.content).toContain('场景一')
|
||||
expect(ch.content).toContain('场景二')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||
import { checkInteractiveGate } from '../../src/main/services/interactive-gate.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('checkInteractiveGate', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
})
|
||||
|
||||
afterEach(() => db.close())
|
||||
|
||||
it('IT-INT-05: rejects empty book', () => {
|
||||
expect(checkInteractiveGate(db).eligible).toBe(false)
|
||||
})
|
||||
|
||||
it('IT-INT-05: accepts book with chapter content', () => {
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
new ChapterRepository(db).create(volId, '第一章', 0, '<p>有正文</p>')
|
||||
expect(checkInteractiveGate(db).eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('IT-INT-05: accepts book with outline only', () => {
|
||||
new OutlineRepository(db).create(null, '节点', 0)
|
||||
expect(checkInteractiveGate(db).eligible).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsePlotsJson, parseNamingRequiredJson } from '../../src/main/services/interactive-parse.service'
|
||||
|
||||
describe('interactive-parse', () => {
|
||||
it('parses three plot options', () => {
|
||||
const json = `{"plots":[{"id":"A","label":"锋芒","summary":"林远展露天赋引起轰动,同门侧目,长老暗中关注其来历与隐患。"},{"id":"B","label":"藏锋","summary":"林远按长老暗示收敛灵力,被安排在外门修行,暗中调查测灵石异变。"},{"id":"C","label":"转折","summary":"测灵石碎裂引发异象,闭关太上长老出关,宗门上下震动。"}]}`
|
||||
const plots = parsePlotsJson(json)
|
||||
expect(plots).toHaveLength(3)
|
||||
expect(plots[0].summary.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('parses naming_required payload', () => {
|
||||
const json = `{"type":"naming_required","elementType":"角色","context":"需要为新同伴命名","options":["名1","名2","名3","名4","名5"]}`
|
||||
const pause = parseNamingRequiredJson(json)
|
||||
expect(pause.options).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
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 { InteractiveWritingService } from '../../src/main/services/interactive-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 }
|
||||
|
||||
describe('interactive plots integration', () => {
|
||||
it('IT-INT-01: suggestPlots returns 3 options from LM Studio', async () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
const flow = service.start(session.id)
|
||||
service.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远参加宗门入门考核,演武场测灵石前众人围观。'
|
||||
)
|
||||
const plots = await service.suggestPlots(flow.id)
|
||||
expect(plots).toHaveLength(3)
|
||||
db.close()
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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 { InteractiveWritingService } from '../../src/main/services/interactive-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 }
|
||||
|
||||
describe('interactive scene integration', () => {
|
||||
const service = (db: DatabaseSync) =>
|
||||
new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
|
||||
it('IT-INT-02: generateScene returns non-empty draft', 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.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远在演武场测试灵根,众人围观。'
|
||||
)
|
||||
const plots = await svc.suggestPlots(flow.id)
|
||||
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
|
||||
const afterGen = await svc.generateScene(flow.id, () => {})
|
||||
if (afterGen.step === 'naming_pause' && afterGen.namingPending) {
|
||||
await svc.resolveNaming(flow.id, afterGen.namingPending.options[0], () => {})
|
||||
}
|
||||
const draft = svc.getSceneDraft(flow.id)
|
||||
expect(draft.trim().length).toBeGreaterThan(50)
|
||||
db.close()
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
|
||||
it('migrates existing v1 database to v2', () => {
|
||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
||||
|
||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
const colNames = cols.map((c) => c.name)
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
|
||||
it('migrates existing v2 database to v3', () => {
|
||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
|
||||
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
|
||||
import schemaV3 from '../../src/main/db/schema-v3.sql?raw'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
function tableExists(db: SqliteDb, name: string): boolean {
|
||||
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
|
||||
return row != null
|
||||
}
|
||||
|
||||
describe('migrate v4', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
it('IT-MIG-04: applies v4 on fresh database', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(4)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
expect(cols.map((c) => c.name)).toContain('origin')
|
||||
})
|
||||
|
||||
it('IT-MIG-04: migrates v3 database to v4', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
description TEXT
|
||||
)`)
|
||||
db.exec(schemaV1)
|
||||
db.exec(schemaV2)
|
||||
db.exec(schemaV3)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial')
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2')
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3')
|
||||
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(4)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
} from '../../src/main/services/naming-check.service'
|
||||
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||
|
||||
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000 }
|
||||
|
||||
describe('naming check integration', () => {
|
||||
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
|
||||
const client = new AiClientService(() => AI_TEST_CONFIG)
|
||||
|
||||
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
|
||||
const prompt = buildNamingPrompt(
|
||||
|
||||
Reference in New Issue
Block a user