feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge
Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, 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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { ChapterBridgeService } from '../../src/main/services/chapter-bridge.service'
|
||||
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||
|
||||
describe('ChapterBridgeService', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-BRIDGE-01: extracts tail and head', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
chapterRepo.create(volId, '上一章', 0, `<p>${'甲'.repeat(400)}</p>`)
|
||||
const cur = chapterRepo.create(volId, '本章', 1, '<p>开头内容</p>')
|
||||
const service = new ChapterBridgeService(db)
|
||||
const result = service.extract(cur.id)
|
||||
expect(result.previousTail.length).toBeLessThanOrEqual(300)
|
||||
expect(result.previousTail.length).toBeGreaterThan(50)
|
||||
expect(result.currentHead).toContain('开头')
|
||||
})
|
||||
|
||||
it('IT-BRIDGE-01: getBridge returns aiSuggestion from LM Studio', async () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
chapterRepo.create(
|
||||
volId,
|
||||
'上一章',
|
||||
0,
|
||||
'<p>林远站在演武场中央,众人目光汇聚,他知道从这一刻起一切都将不同。</p>'
|
||||
)
|
||||
const cur = chapterRepo.create(
|
||||
volId,
|
||||
'本章',
|
||||
1,
|
||||
'<p>晨光透过竹帘,林远深吸一口气推开了门。</p>'
|
||||
)
|
||||
const aiClient = new AiClientService(() => AI_TEST_CONFIG)
|
||||
const service = new ChapterBridgeService(db, aiClient)
|
||||
const result = await service.getBridge(cur.id, true)
|
||||
expect(result.aiSuggestion?.trim().length).toBeGreaterThan(10)
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, 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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { CockpitService } from '../../src/main/services/cockpit.service'
|
||||
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
describe('CockpitService', () => {
|
||||
let db: SqliteDb
|
||||
let settingsDir: string
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-COCK-01: getSummary counts words and ready stock', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-cockpit-'))
|
||||
const settings = new GlobalSettingsService(settingsDir)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
const ch = chapterRepo.create(volId, 'A', 0, '<p>hello world</p>')
|
||||
chapterRepo.setPublishStatus(ch.id, 'ready')
|
||||
const service = new CockpitService(db, settings)
|
||||
const summary = service.getSummary(volId)
|
||||
expect(summary.totalWords).toBeGreaterThan(0)
|
||||
expect(summary.stockReadyCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('KnowledgeRepository', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-KNOW-01: CRUD and approve updates status', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const repo = new KnowledgeRepository(db)
|
||||
const entry = repo.create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石异常',
|
||||
foreshadowStatus: 'buried'
|
||||
})
|
||||
expect(entry.status).toBe('pending')
|
||||
const approved = repo.update(entry.id, { status: 'approved' })
|
||||
expect(approved.status).toBe('approved')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, 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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { BookmarkRepository } from '../../src/main/db/repositories/bookmark.repo'
|
||||
import { KnowledgeService } from '../../src/main/services/knowledge.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('KnowledgeService', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-KNOW-02: forgotten when sort_order gap >= 20', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
const firstId = chapterRepo.create(volId, '第1章', 0, '<p>伏笔</p>').id
|
||||
for (let i = 1; i < 25; i++) {
|
||||
chapterRepo.create(volId, `第${i + 1}章`, i, '<p>x</p>')
|
||||
}
|
||||
const service = new KnowledgeService(db)
|
||||
const entry = service.create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石',
|
||||
status: 'approved',
|
||||
foreshadowStatus: 'buried',
|
||||
sourceChapterId: firstId
|
||||
})
|
||||
expect(service.isForgotten(entry)).toBe(true)
|
||||
const stats = service.getForeshadowStats()
|
||||
expect(stats.forgotten).toBe(1)
|
||||
})
|
||||
|
||||
it('UT-KNOW-03: createFromBookmark creates pending entry', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chId = new ChapterRepository(db).create(volId, '章', 0).id
|
||||
const bm = new BookmarkRepository(db).create(chId, 0, '测灵石发热', 'foreshadow')
|
||||
const service = new KnowledgeService(db)
|
||||
const entry = service.createFromBookmark(bm.id)
|
||||
expect(entry.status).toBe('pending')
|
||||
expect(entry.linkedBookmarkId).toBe(bm.id)
|
||||
})
|
||||
|
||||
it('IT-KNOW-04: batchApprove clears pending', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const service = new KnowledgeService(db)
|
||||
const ids = [
|
||||
service.create({ type: 'fact', title: 'A' }).id,
|
||||
service.create({ type: 'fact', title: 'B' }).id,
|
||||
service.create({ type: 'fact', title: 'C' }).id
|
||||
]
|
||||
expect(service.countPending()).toBe(3)
|
||||
expect(service.batchApprove(ids)).toBe(3)
|
||||
expect(service.countPending()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -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(5)
|
||||
expect(version.v).toBe(6)
|
||||
})
|
||||
|
||||
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(5)
|
||||
expect(version.v).toBe(6)
|
||||
|
||||
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(5)
|
||||
expect(version.v).toBe(6)
|
||||
})
|
||||
|
||||
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(5)
|
||||
expect(version.v).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(5)
|
||||
expect(version.v).toBe(6)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(5)
|
||||
expect(version.v).toBe(6)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
||||
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(5)
|
||||
expect(version.v).toBe(6)
|
||||
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
||||
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
||||
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
||||
@@ -39,6 +39,6 @@ describe('migrate v5', () => {
|
||||
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(5)
|
||||
expect(version.v).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('migrate v6', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-MIG-06: applies v6 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(6)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[]
|
||||
expect(tables.some((t) => t.name === 'knowledge_entries')).toBe(true)
|
||||
expect(tables.some((t) => t.name === 'book_preferences')).toBe(true)
|
||||
expect(tables.some((t) => t.name === 'chapter_bridge_dismiss')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user