diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx
index eb90676..1d42caf 100644
--- a/src/renderer/components/layout/EditorLayout.tsx
+++ b/src/renderer/components/layout/EditorLayout.tsx
@@ -9,6 +9,7 @@ import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
+import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
@@ -90,6 +91,9 @@ export function EditorLayout(): React.JSX.Element {
const switchTarget = useEditStore((s) => s.switchTarget)
const setTarget = useEditStore((s) => s.setTarget)
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
+ const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
+ const writingStats = useWritingStatsStore((s) => s.stats)
+ const refreshWritingStats = useWritingStatsStore((s) => s.refresh)
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
@@ -122,6 +126,11 @@ export function EditorLayout(): React.JSX.Element {
}
}, [currentBookId, target?.kind, target?.id])
+ useEffect(() => {
+ if (!currentBookId) return
+ void refreshWritingStats(currentBookId)
+ }, [currentBookId, lastSaved, refreshWritingStats])
+
useEffect(() => {
if (!currentBookId) return
void ipcCall(() =>
@@ -344,6 +353,19 @@ export function EditorLayout(): React.JSX.Element {
))}
{activePanel === 'chapters' && (
+ {selectedChapterId && currentBookId && (
+
+ )}
@@ -405,6 +427,23 @@ export function EditorLayout(): React.JSX.Element {
/>
{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}
+ {dailyWordGoal > 0 && writingStats && (
+ = 1
+ ? 'goal-met'
+ : writingStats.goalProgress >= 0.8
+ ? 'goal-near'
+ : ''
+ }`}
+ data-testid="status-daily-goal"
+ >
+ {t('status.dailyGoal', {
+ current: writingStats.todayWords,
+ goal: dailyWordGoal
+ })}
+
+ )}
{showStockWarning && (
{t('stock.warning', {
diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx
index 5350a2c..8c74d34 100644
--- a/src/renderer/components/settings/SettingsPage.tsx
+++ b/src/renderer/components/settings/SettingsPage.tsx
@@ -131,13 +131,61 @@ export function SettingsPage(): React.JSX.Element {
}
/>
+
+ {t('settings.dailyWordGoal')}
+
+ void settings.update({
+ dailyWordGoal: Math.max(0, Number(e.target.value) || 0)
+ })
+ }
+ />
+
+
+
+
+
+ {t('settings.extractThreshold')}
+
+ void settings.update({
+ knowledgeExtractConfidenceThreshold: Math.min(
+ 1,
+ Math.max(0, Number(e.target.value) || 0.8)
+ )
+ })
+ }
+ />
+
>
)}
{section === 'ai' &&
}
{section === 'shortcuts' &&
}
{section === 'about' && (
- {t('app.name')} v0.7.0
+ {t('app.name')} v0.8.0
{t('app.tagline')}
diff --git a/src/renderer/stores/useKnowledgeStore.ts b/src/renderer/stores/useKnowledgeStore.ts
index 90be447..3317ecf 100644
--- a/src/renderer/stores/useKnowledgeStore.ts
+++ b/src/renderer/stores/useKnowledgeStore.ts
@@ -32,6 +32,10 @@ interface KnowledgeState {
approve: (bookId: string, id: string) => Promise
reject: (bookId: string, id: string) => Promise
batchApprove: (bookId: string, ids: string[]) => Promise
+ extractChapter: (bookId: string, chapterId: string) => Promise
+ approveMerge: (bookId: string, pendingId: string) => Promise
+ saveMergeAsNew: (bookId: string, pendingId: string) => Promise
+ batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise
filteredEntries: () => KnowledgeEntry[]
}
@@ -82,6 +86,22 @@ export const useKnowledgeStore = create((set, get) => ({
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
await get().load(bookId)
},
+ extractChapter: async (bookId, chapterId) => {
+ await ipcCall(() => window.electronAPI.knowledge.extractChapter(bookId, chapterId))
+ await get().load(bookId)
+ },
+ approveMerge: async (bookId, pendingId) => {
+ await ipcCall(() => window.electronAPI.knowledge.approveMerge(bookId, pendingId))
+ await get().load(bookId)
+ },
+ saveMergeAsNew: async (bookId, pendingId) => {
+ await ipcCall(() => window.electronAPI.knowledge.saveMergeAsNew(bookId, pendingId))
+ await get().load(bookId)
+ },
+ batchApproveHighConfidence: async (bookId, threshold) => {
+ await ipcCall(() => window.electronAPI.knowledge.batchApproveHighConfidence(bookId, threshold))
+ await get().load(bookId)
+ },
filteredEntries: () => {
const { entries, tab, forgottenOnly } = get()
let list = entries
diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts
index ff8e9be..f2e198a 100644
--- a/src/renderer/stores/useSettingsStore.ts
+++ b/src/renderer/stores/useSettingsStore.ts
@@ -24,6 +24,8 @@ export const useSettingsStore = create((set, get) => ({
namingIgnoreWords: [] as string[],
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8,
+ knowledgeAutoExtract: true,
+ knowledgeExtractConfidenceThreshold: 0.8,
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
diff --git a/src/renderer/stores/useWritingStatsStore.ts b/src/renderer/stores/useWritingStatsStore.ts
new file mode 100644
index 0000000..27c03d2
--- /dev/null
+++ b/src/renderer/stores/useWritingStatsStore.ts
@@ -0,0 +1,16 @@
+import { create } from 'zustand'
+import type { WritingStats } from '@shared/types'
+import { ipcCall } from '@renderer/lib/ipc-client'
+
+interface WritingStatsState {
+ stats: WritingStats | null
+ refresh: (bookId: string) => Promise
+}
+
+export const useWritingStatsStore = create((set) => ({
+ stats: null,
+ refresh: async (bookId) => {
+ const stats = await ipcCall(() => window.electronAPI.writing.getStats(bookId))
+ set({ stats })
+ }
+}))
diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css
index a78d86c..3462b17 100644
--- a/src/renderer/styles/layout.css
+++ b/src/renderer/styles/layout.css
@@ -868,6 +868,62 @@
gap: 8px;
}
+.cockpit-writing-section {
+ margin-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.cockpit-today-progress {
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.cockpit-streak {
+ color: var(--accent-light);
+ font-size: 13px;
+}
+
+.cockpit-heatmap-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.cockpit-heatmap-wrap {
+ overflow-x: auto;
+}
+
+.status-daily-goal {
+ margin-left: 12px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.status-daily-goal.goal-near {
+ color: var(--accent-light);
+}
+
+.status-daily-goal.goal-met {
+ color: #3fb950;
+ font-weight: 600;
+}
+
+.kb-confidence-badge {
+ margin-left: 8px;
+ font-size: 11px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+}
+
+.kb-merge-hint {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--accent-light);
+}
+
.bridge-body {
max-height: 420px;
overflow-y: auto;
diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts
index 9238707..a7fa78e 100644
--- a/src/shared/electron-api.d.ts
+++ b/src/shared/electron-api.d.ts
@@ -42,7 +42,9 @@ import type {
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
- PublishStatus
+ PublishStatus,
+ WritingStats,
+ KnowledgeExtractionResult
} from './types'
export interface ElectronAPI {
@@ -349,6 +351,16 @@ export interface ElectronAPI {
bookId: string,
opts?: KnowledgeSuggestOptions
) => Promise>
+ extractChapter: (
+ bookId: string,
+ chapterId: string
+ ) => Promise>
+ approveMerge: (bookId: string, pendingId: string) => Promise>
+ saveMergeAsNew: (bookId: string, pendingId: string) => Promise>
+ batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise>
+ }
+ writing: {
+ getStats: (bookId: string) => Promise>
}
cockpit: {
getSummary: (bookId: string, volumeId?: string) => Promise>
diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts
index 3be105c..68c4594 100644
--- a/src/shared/ipc-channels.ts
+++ b/src/shared/ipc-channels.ts
@@ -112,6 +112,11 @@ export const IPC = {
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
KNOWLEDGE_STATS: 'knowledge:stats',
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
+ KNOWLEDGE_EXTRACT_CHAPTER: 'knowledge:extractChapter',
+ KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge',
+ KNOWLEDGE_SAVE_MERGE_AS_NEW: 'knowledge:saveMergeAsNew',
+ KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
+ WRITING_GET_STATS: 'writing:getStats',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 2c375b0..a645ca2 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -66,6 +66,9 @@ export interface KnowledgeEntry {
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
+ extractionConfidence?: number
+ mergeTargetId?: string
+ extractionBatchId?: string
createdAt: string
updatedAt: string
}
@@ -90,6 +93,38 @@ export interface CockpitSummary {
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
+ writingStats?: WritingStats
+}
+
+export interface WritingDayLog {
+ date: string
+ bookId: string
+ wordCount: number
+}
+
+export interface WritingStats {
+ todayWords: number
+ dailyGoal: number
+ goalProgress: number
+ streakDays: number
+ heatmap: WritingDayLog[]
+}
+
+export interface ExtractedKnowledgeItem {
+ type: KnowledgeType
+ title: string
+ content: string
+ importance?: number
+ confidence: number
+ foreshadowStatus?: ForeshadowStatus
+ suggestedMergeId?: string
+ proposedPatch?: Partial
+}
+
+export interface KnowledgeExtractionResult {
+ batchId: string
+ chapterId: string
+ items: ExtractedKnowledgeItem[]
}
export interface ChapterBridgeResult {
@@ -134,6 +169,8 @@ export interface GlobalSettings {
namingIgnoreWords: string[]
knowledgeAutoSuggest?: boolean
knowledgeSuggestTopN?: number
+ knowledgeAutoExtract?: boolean
+ knowledgeExtractConfidenceThreshold?: number
}
export interface BookMeta {
diff --git a/tests/main/knowledge-extraction.test.ts b/tests/main/knowledge-extraction.test.ts
new file mode 100644
index 0000000..9b62af3
--- /dev/null
+++ b/tests/main/knowledge-extraction.test.ts
@@ -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')
+ })
+})
diff --git a/tests/main/knowledge.service.test.ts b/tests/main/knowledge.service.test.ts
index 0250754..f7bc68f 100644
--- a/tests/main/knowledge.service.test.ts
+++ b/tests/main/knowledge.service.test.ts
@@ -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)
+ })
})
diff --git a/tests/main/migrate-v2.test.ts b/tests/main/migrate-v2.test.ts
index 655df10..75de6d0 100644
--- a/tests/main/migrate-v2.test.ts
+++ b/tests/main/migrate-v2.test.ts
@@ -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)
diff --git a/tests/main/migrate-v3.test.ts b/tests/main/migrate-v3.test.ts
index 1184d79..a2c44db 100644
--- a/tests/main/migrate-v3.test.ts
+++ b/tests/main/migrate-v3.test.ts
@@ -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)
})
})
diff --git a/tests/main/migrate-v4.test.ts b/tests/main/migrate-v4.test.ts
index 03c903e..95ac13b 100644
--- a/tests/main/migrate-v4.test.ts
+++ b/tests/main/migrate-v4.test.ts
@@ -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)
})
})
diff --git a/tests/main/migrate-v5.test.ts b/tests/main/migrate-v5.test.ts
index cbc86fd..72c281b 100644
--- a/tests/main/migrate-v5.test.ts
+++ b/tests/main/migrate-v5.test.ts
@@ -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)
})
})
diff --git a/tests/main/migrate-v6.test.ts b/tests/main/migrate-v6.test.ts
index 41da5cd..8b019a4 100644
--- a/tests/main/migrate-v6.test.ts
+++ b/tests/main/migrate-v6.test.ts
@@ -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 }[]
diff --git a/tests/main/migrate-v7.test.ts b/tests/main/migrate-v7.test.ts
new file mode 100644
index 0000000..244fd8e
--- /dev/null
+++ b/tests/main/migrate-v7.test.ts
@@ -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)
+ })
+})
diff --git a/tests/main/writing-log.test.ts b/tests/main/writing-log.test.ts
new file mode 100644
index 0000000..d28ae85
--- /dev/null
+++ b/tests/main/writing-log.test.ts
@@ -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, '一二三四五六七八九十
')
+ 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, 'ab
')
+ const tracker = new ChapterWritingTracker(db, bookId, writingLogs)
+ const updated = chapterRepo.update(ch.id, { content: 'abcd
' })
+ tracker.recordDelta(ch.id, updated.wordCount)
+ const updated2 = chapterRepo.update(ch.id, { content: 'abcdef
' })
+ tracker.recordDelta(ch.id, updated2.wordCount)
+ expect(writingLogs.getStats(bookId).todayWords).toBe(updated2.wordCount)
+ })
+})