feat: ship v1.0.0 with character graph and writing analytics
Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { CharacterGraphService } from '../../src/main/services/character-graph.service'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { closeAllBookDbs } from '../../src/main/db/connection'
|
||||
|
||||
describe('CharacterGraphService', () => {
|
||||
let dir: string
|
||||
let registry: BookRegistryService
|
||||
let bookId: string
|
||||
let charA: string
|
||||
let charB: string
|
||||
let charC: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'bilin-graph-'))
|
||||
registry = new BookRegistryService(dir)
|
||||
const meta = registry.create({ name: '图谱书', category: '测试', createSampleChapter: false })
|
||||
bookId = meta.id
|
||||
const repo = new SettingRepository(registry.getDb(bookId))
|
||||
charA = repo.create('character', '角色A').id
|
||||
charB = repo.create('character', '角色B').id
|
||||
charC = repo.create('character', '角色C').id
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeAllBookDbs()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-GRAPH-01: getData returns nodes and edges', () => {
|
||||
const svc = new CharacterGraphService(registry)
|
||||
svc.upsertRelationship(bookId, charA, { targetId: charB, label: '朋友', intimacy: 3 })
|
||||
const data = svc.getData(bookId)
|
||||
expect(data.nodes.length).toBe(3)
|
||||
expect(data.edges).toHaveLength(1)
|
||||
expect(data.edges[0].label).toBe('朋友')
|
||||
})
|
||||
|
||||
it('UT-GRAPH-02: bidirectional edges dedupe to max intimacy', () => {
|
||||
const svc = new CharacterGraphService(registry)
|
||||
svc.upsertRelationship(bookId, charA, { targetId: charB, label: '同事', intimacy: 2 })
|
||||
svc.upsertRelationship(bookId, charB, { targetId: charA, label: '挚友', intimacy: 4 })
|
||||
const data = svc.getData(bookId)
|
||||
expect(data.edges).toHaveLength(1)
|
||||
expect(data.edges[0].intimacy).toBe(4)
|
||||
})
|
||||
|
||||
it('UT-GRAPH-03: connected filter hides isolated nodes', () => {
|
||||
const svc = new CharacterGraphService(registry)
|
||||
svc.upsertRelationship(bookId, charA, { targetId: charB, label: '师徒', intimacy: 5 })
|
||||
const data = svc.getData(bookId, 'connected')
|
||||
expect(data.nodes).toHaveLength(2)
|
||||
expect(data.nodes.some((n) => n.id === charC)).toBe(false)
|
||||
})
|
||||
|
||||
it('IT-GRAPH-01: upsert and delete relationship', () => {
|
||||
const svc = new CharacterGraphService(registry)
|
||||
const rel = svc.upsertRelationship(bookId, charA, {
|
||||
id: randomUUID(),
|
||||
targetId: charB,
|
||||
label: '恋人',
|
||||
intimacy: 5
|
||||
})
|
||||
expect(svc.getData(bookId).edges).toHaveLength(1)
|
||||
svc.deleteRelationship(bookId, charA, rel.id)
|
||||
expect(svc.getData(bookId).edges).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
|
||||
import { WritingLogService } from '../../src/main/services/writing-log.service'
|
||||
import { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo'
|
||||
import { WritingSessionService } from '../../src/main/services/writing-session.service'
|
||||
import { PomodoroDailyRepository } from '../../src/main/db/repositories/pomodoro-daily.repo'
|
||||
import { WritingAnalyticsService, POV_NONE } from '../../src/main/services/writing-analytics.service'
|
||||
import { trackerFor } from '../../src/main/services/chapter-writing-tracker.service'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { closeAllBookDbs } from '../../src/main/db/connection'
|
||||
|
||||
describe('WritingAnalyticsService', () => {
|
||||
let booksDir: string
|
||||
let userDir: string
|
||||
let settingsDir: string
|
||||
let registry: BookRegistryService
|
||||
let writingLogs: WritingLogService
|
||||
let sessionRepo: WritingSessionRepository
|
||||
let sessions: WritingSessionService
|
||||
let pomodoroDaily: PomodoroDailyRepository
|
||||
let analytics: WritingAnalyticsService
|
||||
let bookId: string
|
||||
let logRepo: WritingLogRepository
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-08T14:30:00.000Z'))
|
||||
booksDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-books-'))
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-logs-'))
|
||||
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-settings-'))
|
||||
registry = new BookRegistryService(booksDir)
|
||||
const settings = new GlobalSettingsService(settingsDir)
|
||||
settings.update({ ...settings.get(), dailyWordGoal: 500 })
|
||||
logRepo = new WritingLogRepository(userDir)
|
||||
writingLogs = new WritingLogService(logRepo, settings)
|
||||
sessionRepo = new WritingSessionRepository(logRepo.getDb())
|
||||
sessions = new WritingSessionService(sessionRepo)
|
||||
pomodoroDaily = new PomodoroDailyRepository(logRepo.getDb())
|
||||
analytics = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
|
||||
bookId = registry.create({ name: '分析书', category: '测试', createSampleChapter: false }).id
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
logRepo?.close()
|
||||
closeAllBookDbs()
|
||||
rmSync(booksDir, { recursive: true, force: true })
|
||||
rmSync(userDir, { recursive: true, force: true })
|
||||
rmSync(settingsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-ANALYTICS-01: getSummary includes today speed from sessions', () => {
|
||||
const db = registry.getDb(bookId)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
let chapter = chapterRepo.create(volId, '第一章', 0, '<p>测试内容</p>')
|
||||
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
chapter = chapterRepo.update(chapter.id, { content: '<p>测试内容扩展更多汉字</p>' })
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
const summary = analytics.getSummary(bookId)
|
||||
expect(summary.todayWords).toBeGreaterThan(0)
|
||||
expect(summary.todaySpeedWpm).not.toBeNull()
|
||||
expect(summary.todaySpeedWpm!).toBeGreaterThan(0)
|
||||
expect(summary.hourlyHeatmap.some((v) => v > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('UT-ANALYTICS-02: povDistribution groups by povCharacterId', () => {
|
||||
const db = registry.getDb(bookId)
|
||||
const settingRepo = new SettingRepository(db)
|
||||
const hero = settingRepo.create('character', '主角').id
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const ch1 = new ChapterRepository(db).create(volId, 'A', 0, '<p>一二三四五</p>')
|
||||
const ch2 = new ChapterRepository(db).create(volId, 'B', 1, '<p>六七八九十</p>')
|
||||
new ChapterRepository(db).update(ch1.id, { povCharacterId: hero })
|
||||
const summary = analytics.getSummary(bookId)
|
||||
const heroRow = summary.povDistribution.find((p) => p.characterId === hero)
|
||||
const noneRow = summary.povDistribution.find((p) => p.characterId === POV_NONE)
|
||||
expect(heroRow!.words).toBeGreaterThan(0)
|
||||
expect(noneRow!.words).toBeGreaterThan(0)
|
||||
expect(ch2.wordCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => {
|
||||
const db = registry.getDb(bookId)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
let chapter = chapterRepo.create(volId, '章', 0, '<p>一二三四五</p>')
|
||||
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
chapter = chapterRepo.update(chapter.id, { content: '<p>一二三四五六七八九十</p>' })
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
const rows = sessionRepo.listForBookDate(bookId, '2026-07-08')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].wordDelta).toBeGreaterThan(0)
|
||||
const summary = analytics.getSummary(bookId)
|
||||
expect(summary.todaySpeedWpm).not.toBeNull()
|
||||
expect(summary.todaySpeedWpm!).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,8 @@ 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 { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo'
|
||||
import { WritingSessionService } from '../../src/main/services/writing-session.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'
|
||||
@@ -84,10 +86,12 @@ describe('ChapterWritingTracker', () => {
|
||||
settings = new GlobalSettingsService(settingsDir)
|
||||
repo = new WritingLogRepository(userDir)
|
||||
writingLogs = new WritingLogService(repo, settings)
|
||||
const sessionRepo = new WritingSessionRepository(repo.getDb())
|
||||
const sessions = new WritingSessionService(sessionRepo)
|
||||
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)
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs, sessions)
|
||||
tracker.recordDelta(chapter.id, 5)
|
||||
tracker.supplementOnFinish(chapter.id, chapter.wordCount)
|
||||
expect(writingLogs.getStats(bookId).todayWords).toBe(chapter.wordCount)
|
||||
@@ -101,11 +105,13 @@ describe('ChapterWritingTracker', () => {
|
||||
settings = new GlobalSettingsService(settingsDir)
|
||||
repo = new WritingLogRepository(userDir)
|
||||
writingLogs = new WritingLogService(repo, settings)
|
||||
const sessionRepo = new WritingSessionRepository(repo.getDb())
|
||||
const sessions = new WritingSessionService(sessionRepo)
|
||||
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 tracker = new ChapterWritingTracker(db, bookId, writingLogs, sessions)
|
||||
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>' })
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
|
||||
import { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo'
|
||||
import { WritingSessionService } from '../../src/main/services/writing-session.service'
|
||||
|
||||
describe('WritingSessionRepository', () => {
|
||||
let userDir: string
|
||||
let logRepo: WritingLogRepository
|
||||
let repo: WritingSessionRepository
|
||||
|
||||
afterEach(() => {
|
||||
logRepo?.close()
|
||||
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => {
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-session-'))
|
||||
logRepo = new WritingLogRepository(userDir)
|
||||
repo = new WritingSessionRepository(logRepo.getDb())
|
||||
const id = randomUUID()
|
||||
repo.insert({
|
||||
id,
|
||||
bookId: 'b1',
|
||||
chapterId: 'c1',
|
||||
startedAt: '2026-07-08T10:00:00.000Z',
|
||||
endedAt: '2026-07-08T10:05:00.000Z',
|
||||
wordDelta: 50,
|
||||
date: '2026-07-08'
|
||||
})
|
||||
repo.updateActivity(id, '2026-07-08T10:10:00.000Z', 30)
|
||||
expect(repo.getById(id)!.wordDelta).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WritingSessionService', () => {
|
||||
let userDir: string
|
||||
let logRepo: WritingLogRepository
|
||||
let repo: WritingSessionRepository
|
||||
let service: WritingSessionService
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-08T10:00:00.000Z'))
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-session-svc-'))
|
||||
logRepo = new WritingLogRepository(userDir)
|
||||
repo = new WritingSessionRepository(logRepo.getDb())
|
||||
service = new WritingSessionService(repo)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
logRepo?.close()
|
||||
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-SESSION-01: within 5min merges into one session', () => {
|
||||
service.recordActivity('b1', 'c1', 50)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
service.recordActivity('b1', 'c1', 30)
|
||||
const rows = repo.listForBookDate('b1', '2026-07-08')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].wordDelta).toBe(80)
|
||||
})
|
||||
|
||||
it('UT-SESSION-02: after 6min creates two sessions', () => {
|
||||
service.recordActivity('b1', 'c1', 50)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000)
|
||||
service.recordActivity('b1', 'c1', 30)
|
||||
const rows = repo.listForBookDate('b1', '2026-07-08')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].wordDelta).toBe(50)
|
||||
expect(rows[1].wordDelta).toBe(30)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user