feat: ship v0.8.0 with writing logs and AI knowledge extraction
Deliver P5.2 writing day logs, cockpit heatmap, streak tracking, and chapter knowledge auto-extraction with merge review. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseExtractionJson } from '../../src/main/services/knowledge-extraction-prompt'
|
||||
|
||||
describe('knowledge extraction', () => {
|
||||
it('UT-EXT-01: parseExtractionJson parses valid array', () => {
|
||||
const raw = `[{"type":"fact","title":"测灵石","content":"石头发热","confidence":0.9}]`
|
||||
const items = parseExtractionJson(raw)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].title).toBe('测灵石')
|
||||
expect(items[0].confidence).toBe(0.9)
|
||||
})
|
||||
|
||||
it('UT-EXT-02: parseExtractionJson handles markdown fence and suggestedMergeId', () => {
|
||||
const raw = '```json\n[{"type":"character","title":"林凡","content":"主角","confidence":0.85,"suggestedMergeId":"abc-123"}]\n```'
|
||||
const items = parseExtractionJson(raw)
|
||||
expect(items[0].suggestedMergeId).toBe('abc-123')
|
||||
})
|
||||
})
|
||||
@@ -58,4 +58,29 @@ describe('KnowledgeService', () => {
|
||||
expect(service.batchApprove(ids)).toBe(3)
|
||||
expect(service.countPending()).toBe(0)
|
||||
})
|
||||
|
||||
it('IT-EXT-03: approveMerge updates target and rejects pending', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const service = new KnowledgeService(db)
|
||||
const target = service.create({
|
||||
type: 'character',
|
||||
title: '林凡',
|
||||
content: '少年',
|
||||
status: 'approved'
|
||||
})
|
||||
const pending = service.create({
|
||||
type: 'character',
|
||||
title: '林凡(更新)',
|
||||
content: '筑基期修士',
|
||||
status: 'pending'
|
||||
})
|
||||
db.prepare('UPDATE knowledge_entries SET merge_target_id = ? WHERE id = ?').run(
|
||||
target.id,
|
||||
pending.id
|
||||
)
|
||||
const updated = service.approveMerge(pending.id)
|
||||
expect(updated.content).toBe('筑基期修士')
|
||||
expect(service.list({ status: 'pending' })).toHaveLength(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(6)
|
||||
expect(version.v).toBe(7)
|
||||
})
|
||||
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
})
|
||||
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(6)
|
||||
expect(version.v).toBe(7)
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
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(6)
|
||||
expect(version.v).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v6', () => {
|
||||
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)
|
||||
expect(version.v).toBe(7)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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 v7', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => {
|
||||
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(7)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
||||
.get()
|
||||
expect(tables).toBeTruthy()
|
||||
const cols = db.prepare('PRAGMA table_info(knowledge_entries)').all() as Array<{ name: string }>
|
||||
expect(cols.some((c) => c.name === 'extraction_confidence')).toBe(true)
|
||||
expect(cols.some((c) => c.name === 'merge_target_id')).toBe(true)
|
||||
expect(cols.some((c) => c.name === 'extraction_batch_id')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
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 { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
|
||||
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||
import { WritingLogService } from '../../src/main/services/writing-log.service'
|
||||
import { ChapterWritingTracker } from '../../src/main/services/chapter-writing-tracker.service'
|
||||
import { addDays, localDateString } from '../../src/main/services/writing-date.util'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('WritingLogService', () => {
|
||||
let userDir: string
|
||||
let settingsDir: string
|
||||
let settings: GlobalSettingsService
|
||||
let repo: WritingLogRepository
|
||||
let service: WritingLogService
|
||||
|
||||
afterEach(() => {
|
||||
repo?.close()
|
||||
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function setup(goal = 1000): void {
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-wlog-'))
|
||||
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-wlog-settings-'))
|
||||
settings = new GlobalSettingsService(settingsDir)
|
||||
settings.update({ ...settings.get(), dailyWordGoal: goal })
|
||||
repo = new WritingLogRepository(userDir)
|
||||
service = new WritingLogService(repo, settings)
|
||||
}
|
||||
|
||||
it('UT-LOG-01: addToday accumulates +200', () => {
|
||||
setup()
|
||||
const bookId = 'book-1'
|
||||
expect(service.addToday(bookId, 200)).toBe(200)
|
||||
expect(service.addToday(bookId, 200)).toBe(400)
|
||||
expect(service.getStats(bookId).todayWords).toBe(400)
|
||||
})
|
||||
|
||||
it('UT-LOG-02: negative delta clamps at 0', () => {
|
||||
setup()
|
||||
const bookId = 'book-1'
|
||||
service.addToday(bookId, 100)
|
||||
expect(service.addToday(bookId, -200)).toBe(0)
|
||||
})
|
||||
|
||||
it('UT-LOG-04: streak counts consecutive goal-met days', () => {
|
||||
setup(100)
|
||||
const bookId = 'book-1'
|
||||
const today = localDateString()
|
||||
repo.addToday(bookId, 150, addDays(today, -2))
|
||||
repo.addToday(bookId, 120, addDays(today, -1))
|
||||
repo.addToday(bookId, 80, today)
|
||||
expect(service.getStats(bookId).streakDays).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChapterWritingTracker', () => {
|
||||
let db: SqliteDb
|
||||
let userDir: string
|
||||
let settingsDir: string
|
||||
let settings: GlobalSettingsService
|
||||
let writingLogs: WritingLogService
|
||||
let repo: WritingLogRepository
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
repo?.close()
|
||||
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-LOG-03: supplementOnFinish fills gap', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-tracker-'))
|
||||
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-tracker-settings-'))
|
||||
settings = new GlobalSettingsService(settingsDir)
|
||||
repo = new WritingLogRepository(userDir)
|
||||
writingLogs = new WritingLogService(repo, settings)
|
||||
const bookId = 'book-1'
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapter = new ChapterRepository(db).create(volId, '第一章', 0, '<p>一二三四五六七八九十</p>')
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs)
|
||||
tracker.recordDelta(chapter.id, 5)
|
||||
tracker.supplementOnFinish(chapter.id, chapter.wordCount)
|
||||
expect(writingLogs.getStats(bookId).todayWords).toBe(chapter.wordCount)
|
||||
})
|
||||
|
||||
it('IT-LOG-01: recordDelta tracks chapter updates', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-itlog-'))
|
||||
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-itlog-settings-'))
|
||||
settings = new GlobalSettingsService(settingsDir)
|
||||
repo = new WritingLogRepository(userDir)
|
||||
writingLogs = new WritingLogService(repo, settings)
|
||||
const bookId = 'book-1'
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
const ch = chapterRepo.create(volId, 'A', 0, '<p>ab</p>')
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs)
|
||||
const updated = chapterRepo.update(ch.id, { content: '<p>abcd</p>' })
|
||||
tracker.recordDelta(ch.id, updated.wordCount)
|
||||
const updated2 = chapterRepo.update(ch.id, { content: '<p>abcdef</p>' })
|
||||
tracker.recordDelta(ch.id, updated2.wordCount)
|
||||
expect(writingLogs.getStats(bookId).todayWords).toBe(updated2.wordCount)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user