feat: ship v0.9.0 with pomodoro, achievements, and injection history

Add pomodoro timer with goal notifications, writing streak milestones, and knowledge injection logging with UI previews across AI writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 19:15:52 +08:00
parent 255f1a99a0
commit d4122c8f95
53 changed files with 1679 additions and 80 deletions
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => []
},
Notification: {
isSupported: () => false
}
}))
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
import { WritingMilestoneRepository } from '../../src/main/db/repositories/writing-milestone.repo'
import { GlobalSettingsService } from '../../src/main/services/global-settings'
import { WritingLogService } from '../../src/main/services/writing-log.service'
import { GoalNotificationService } from '../../src/main/services/goal-notification.service'
import { AchievementService } from '../../src/main/services/achievement.service'
import { addDays, localDateString } from '../../src/main/services/writing-date.util'
describe('WritingMilestoneRepository', () => {
let userDir: string
let repo: WritingLogRepository
let milestoneRepo: WritingMilestoneRepository
afterEach(() => {
repo?.close()
if (userDir) rmSync(userDir, { recursive: true, force: true })
})
it('UT-ACH-01: unlock streak_7 once', () => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-'))
repo = new WritingLogRepository(userDir)
milestoneRepo = new WritingMilestoneRepository(repo.getDb())
milestoneRepo.unlock('b1', 'streak_7')
expect(milestoneRepo.has('b1', 'streak_7')).toBe(true)
expect(milestoneRepo.unlock('b1', 'streak_7')).toBeNull()
})
})
describe('AchievementService', () => {
let userDir: string
let settingsDir: string
let settings: GlobalSettingsService
let writingRepo: WritingLogRepository
let writingLogs: WritingLogService
let milestoneRepo: WritingMilestoneRepository
let notify: GoalNotificationService
let achievement: AchievementService
afterEach(() => {
writingRepo?.close()
if (userDir) rmSync(userDir, { recursive: true, force: true })
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
})
function setup(goal = 100): void {
userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-svc-'))
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-ach-settings-'))
settings = new GlobalSettingsService(settingsDir)
settings.update({
...settings.get(),
dailyWordGoal: goal,
enableGoalNotifications: false
})
writingRepo = new WritingLogRepository(userDir)
writingLogs = new WritingLogService(writingRepo, settings)
milestoneRepo = new WritingMilestoneRepository(writingRepo.getDb())
notify = new GoalNotificationService(settings)
achievement = new AchievementService(milestoneRepo, writingLogs, settings, notify)
writingLogs.setGoalHooks(achievement, notify)
}
it('UT-ACH-02: streakDays=7 unlocks milestone and notifies once', () => {
setup(100)
const bookId = 'book-1'
const today = localDateString()
for (let i = 1; i <= 6; i++) {
writingRepo.addToday(bookId, 150, addDays(today, -i))
}
writingRepo.addToday(bookId, 120, today)
vi.spyOn(notify, 'notifyMilestone')
writingLogs.addToday(bookId, 1)
expect(milestoneRepo.has(bookId, 'streak_7')).toBe(true)
expect(notify.notifyMilestone).toHaveBeenCalledTimes(1)
writingLogs.addToday(bookId, 1)
expect(notify.notifyMilestone).toHaveBeenCalledTimes(1)
})
})
+52
View File
@@ -0,0 +1,52 @@
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 { KnowledgeInjectionRepository } from '../../src/main/db/repositories/knowledge-injection.repo'
import { KnowledgeInjectionService } from '../../src/main/services/knowledge-injection.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('KnowledgeInjectionRepository', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-INJ-01: insertBatch creates rows listable by entry', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const knowledge = new KnowledgeRepository(db)
const entry = knowledge.create({ type: 'fact', title: '测试', status: 'approved' })
const repo = new KnowledgeInjectionRepository(db)
repo.insertBatch([
{ knowledgeEntryId: entry.id, flowMode: 'wizard', flowId: 'flow-1', chapterId: 'ch-1' },
{ knowledgeEntryId: entry.id, flowMode: 'interactive', flowId: 'flow-2' },
{ knowledgeEntryId: entry.id, flowMode: 'auto', flowId: 'flow-3', chapterId: 'ch-2' }
])
const logs = repo.listForEntry(entry.id)
expect(logs).toHaveLength(3)
expect(logs.map((l) => l.flowMode).sort()).toEqual(['auto', 'interactive', 'wizard'])
})
})
describe('KnowledgeInjectionService', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('IT-INJ-01: record only approved knowledge entries', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const knowledge = new KnowledgeRepository(db)
const approved = knowledge.create({ type: 'fact', title: '已审', status: 'approved' })
const pending = knowledge.create({ type: 'fact', title: '待审', status: 'pending' })
const service = new KnowledgeInjectionService(db)
service.record({
knowledgeIds: [approved.id, pending.id],
flowMode: 'interactive',
flowId: 'flow-x',
chapterId: 'ch-1'
})
const logs = service.listForEntry(approved.id)
expect(logs).toHaveLength(1)
expect(logs[0].flowMode).toBe('interactive')
expect(service.listForEntry(pending.id)).toHaveLength(0)
})
})
+2 -2
View File
@@ -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(7)
expect(version.v).toBe(8)
})
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(7)
expect(version.v).toBe(8)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
const colNames = cols.map((c) => c.name)
+2 -2
View File
@@ -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(7)
expect(version.v).toBe(8)
})
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(7)
expect(version.v).toBe(8)
})
})
+2 -2
View File
@@ -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(7)
expect(version.v).toBe(8)
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(7)
expect(version.v).toBe(8)
expect(tableExists(db, 'interactive_flows')).toBe(true)
})
})
+2 -2
View File
@@ -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(7)
expect(version.v).toBe(8)
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(7)
expect(version.v).toBe(8)
})
})
+1 -1
View File
@@ -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(7)
expect(version.v).toBe(8)
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]
+1 -1
View File
@@ -11,7 +11,7 @@ describe('migrate v7', () => {
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)
expect(version.v).toBe(8)
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
.get()
+27
View File
@@ -0,0 +1,27 @@
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 v8', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-MIG-08: v7 database migrates to v8 with injection table', () => {
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(8)
const table = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
)
.get()
expect(table).toBeTruthy()
const cols = db.prepare('PRAGMA table_info(knowledge_injection_logs)').all() as Array<{
name: string
}>
expect(cols.some((c) => c.name === 'knowledge_entry_id')).toBe(true)
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => []
},
Notification: {
isSupported: () => false
}
}))
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
import { PomodoroDailyRepository } from '../../src/main/db/repositories/pomodoro-daily.repo'
import { GlobalSettingsService } from '../../src/main/services/global-settings'
import { WritingLogService } from '../../src/main/services/writing-log.service'
import { GoalNotificationService } from '../../src/main/services/goal-notification.service'
import { PomodoroService } from '../../src/main/services/pomodoro.service'
describe('PomodoroService', () => {
let userDir: string
let settingsDir: string
let settings: GlobalSettingsService
let writingRepo: WritingLogRepository
let writingLogs: WritingLogService
let pomodoroDaily: PomodoroDailyRepository
let notify: GoalNotificationService
let pomodoro: PomodoroService
beforeEach(() => {
vi.useFakeTimers()
process.env.BILIN_E2E = '1'
process.env.POMODORO_TEST_SEC = '3'
userDir = mkdtempSync(join(tmpdir(), 'bilin-pom-'))
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-pom-settings-'))
settings = new GlobalSettingsService(settingsDir)
writingRepo = new WritingLogRepository(userDir)
writingLogs = new WritingLogService(writingRepo, settings)
pomodoroDaily = new PomodoroDailyRepository(writingRepo.getDb())
notify = new GoalNotificationService(settings)
vi.spyOn(notify, 'notifyPomodoroComplete')
vi.spyOn(notify, 'notifyPomodoroBookSwitch')
pomodoro = new PomodoroService(writingLogs, pomodoroDaily, settings, notify)
})
afterEach(() => {
vi.useRealTimers()
delete process.env.POMODORO_TEST_SEC
writingRepo?.close()
if (userDir) rmSync(userDir, { recursive: true, force: true })
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
})
it('UT-POM-01: complete records word delta in pomodoro_daily', () => {
const bookId = 'book-1'
writingLogs.addToday(bookId, 100)
pomodoro.start(bookId)
writingLogs.addToday(bookId, 50)
vi.advanceTimersByTime(3000)
expect(pomodoroDaily.getTodayCount(bookId)).toBe(1)
expect(notify.notifyPomodoroComplete).toHaveBeenCalledWith(50)
expect(pomodoro.getState().running).toBe(false)
})
it('UT-POM-02: start different book cancels previous', () => {
pomodoro.start('book-a')
expect(pomodoro.getState().bookId).toBe('book-a')
pomodoro.start('book-b')
expect(notify.notifyPomodoroBookSwitch).toHaveBeenCalled()
expect(pomodoro.getState().bookId).toBe('book-b')
expect(pomodoro.getState().running).toBe(true)
})
})