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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user