feat: deliver P2 v0.2.0 with unified editor, search, and snapshots
Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { BookmarkRepository } from '../../src/main/db/repositories/bookmark.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('BookmarkRepository', () => {
|
||||
let db: SqliteDb
|
||||
let repo: BookmarkRepository
|
||||
let chapterId: string
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
repo = new BookmarkRepository(db)
|
||||
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0).id
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('creates and lists bookmarks by chapter', () => {
|
||||
repo.create(chapterId, 100, '待办:补充描写', 'todo')
|
||||
expect(repo.listByChapter(chapterId)).toHaveLength(1)
|
||||
expect(repo.listByChapter(chapterId)[0].label).toBe('待办:补充描写')
|
||||
})
|
||||
|
||||
it('resolves bookmark', () => {
|
||||
const bm = repo.create(chapterId, 50, '伏笔', 'foreshadow')
|
||||
repo.resolve(bm.id, true)
|
||||
expect(repo.get(bm.id)!.resolved).toBe(true)
|
||||
expect(repo.list(undefined, false)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes bookmark', () => {
|
||||
const bm = repo.create(chapterId, 0, '研究笔记', 'research')
|
||||
repo.delete(bm.id)
|
||||
expect(repo.get(bm.id)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { InspirationRepository } from '../../src/main/db/repositories/inspiration.repo'
|
||||
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('InspirationRepository', () => {
|
||||
let db: SqliteDb
|
||||
let repo: InspirationRepository
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
repo = new InspirationRepository(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('creates and lists inspirations', () => {
|
||||
repo.create('标题', '内容', ['tag1'])
|
||||
expect(repo.list()).toHaveLength(1)
|
||||
expect(repo.list()[0].tags).toEqual(['tag1'])
|
||||
})
|
||||
|
||||
it('converts to outline', () => {
|
||||
const item = repo.create('灵感标题', '<p>灵感正文</p>')
|
||||
const outlineRepo = new OutlineRepository(db)
|
||||
const outline = repo.convertToOutline(item.id, outlineRepo)
|
||||
expect(repo.get(item.id)).toBeNull()
|
||||
expect(outline.title).toBe('灵感标题')
|
||||
expect(outline.description).toContain('灵感正文')
|
||||
})
|
||||
|
||||
it('converts to setting', () => {
|
||||
const item = repo.create('角色灵感', '外貌描述')
|
||||
const settingRepo = new SettingRepository(db)
|
||||
const setting = repo.convertToSetting(item.id, 'character', settingRepo)
|
||||
expect(repo.get(item.id)).toBeNull()
|
||||
expect(setting.type).toBe('character')
|
||||
expect(setting.name).toBe('角色灵感')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
function tableExists(db: SqliteDb, name: string): boolean {
|
||||
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
|
||||
return row != null
|
||||
}
|
||||
|
||||
describe('migrate v2', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
it('applies v1 and v2 on fresh database', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
|
||||
expect(tableExists(db, 'volumes')).toBe(true)
|
||||
expect(tableExists(db, 'chapters')).toBe(true)
|
||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||
expect(tableExists(db, 'settings')).toBe(true)
|
||||
expect(tableExists(db, 'inspiration')).toBe(true)
|
||||
expect(tableExists(db, 'snapshots')).toBe(true)
|
||||
expect(tableExists(db, 'bookmarks')).toBe(true)
|
||||
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(2)
|
||||
})
|
||||
|
||||
it('migrates existing v1 database to v2', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
description TEXT
|
||||
)`)
|
||||
db.exec(schemaV1)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
|
||||
|
||||
migrate(db)
|
||||
|
||||
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(2)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
const colNames = cols.map((c) => c.name)
|
||||
expect(colNames).toContain('publish_status')
|
||||
expect(colNames).toContain('summary')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('OutlineRepository', () => {
|
||||
let db: SqliteDb
|
||||
let repo: OutlineRepository
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
repo = new OutlineRepository(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('creates nested outline items', () => {
|
||||
const parent = repo.create(null, '第一幕', 0)
|
||||
const child = repo.create(parent.id, '开端', 0)
|
||||
expect(repo.listTree()).toHaveLength(1)
|
||||
expect(repo.listTree()[0].children).toHaveLength(1)
|
||||
expect(repo.listTree()[0].children![0].id).toBe(child.id)
|
||||
})
|
||||
|
||||
it('lists uncovered items without chapter link', () => {
|
||||
const item = repo.create(null, '未覆盖', 0)
|
||||
repo.update(item.id, { chapterId: null })
|
||||
expect(repo.listUncovered()).toHaveLength(1)
|
||||
expect(repo.listUncovered()[0].id).toBe(item.id)
|
||||
})
|
||||
|
||||
it('deletes parent and cascades children', () => {
|
||||
const parent = repo.create(null, '父节点', 0)
|
||||
repo.create(parent.id, '子节点', 0)
|
||||
repo.delete(parent.id)
|
||||
expect(repo.listFlat()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('moves item to new parent', () => {
|
||||
const a = repo.create(null, 'A', 0)
|
||||
const b = repo.create(null, 'B', 1)
|
||||
const child = repo.create(a.id, 'child', 0)
|
||||
repo.move(child.id, b.id, 0)
|
||||
expect(repo.get(child.id)!.parentId).toBe(b.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { ftsSync } from '../../src/main/services/fts-sync.service'
|
||||
import { searchService } from '../../src/main/services/search.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('SearchService', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||
const ch = new ChapterRepository(db).create(vol.id, '第一章', 0, '林远走进青云山')
|
||||
ftsSync.upsert(db, 'chapter', ch.id, ch.title, ch.content)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('finds chapter by FTS query', () => {
|
||||
const results = searchService.query(db, { text: '青云山' })
|
||||
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||
expect(results[0].kind).toBe('chapter')
|
||||
expect(results[0].snippet).toContain('青云')
|
||||
})
|
||||
|
||||
it('returns dry-run replace preview', () => {
|
||||
const { count, previews } = searchService.replace(db, {
|
||||
text: '林远',
|
||||
replacement: '李明',
|
||||
dryRun: true
|
||||
})
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
expect(previews.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('supports regex fallback search', () => {
|
||||
const results = searchService.query(db, { text: '林.+山', regex: true })
|
||||
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('SettingRepository', () => {
|
||||
let db: SqliteDb
|
||||
let repo: SettingRepository
|
||||
let chapterId: string
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
repo = new SettingRepository(db)
|
||||
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0).id
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('creates and lists settings by type', () => {
|
||||
repo.create('character', '林远')
|
||||
repo.create('location', '青云山')
|
||||
expect(repo.list('character')).toHaveLength(1)
|
||||
expect(repo.list('character')[0].name).toBe('林远')
|
||||
})
|
||||
|
||||
it('syncs chapter refs', () => {
|
||||
const setting = repo.create('character', '主角')
|
||||
repo.syncChapterRefs(setting.id, [chapterId])
|
||||
const updated = repo.get(setting.id)!
|
||||
expect(updated.chapterIds).toEqual([chapterId])
|
||||
})
|
||||
|
||||
it('updates and deletes setting', () => {
|
||||
const setting = repo.create('character', '旧名')
|
||||
repo.update(setting.id, { name: '新名' })
|
||||
expect(repo.get(setting.id)!.name).toBe('新名')
|
||||
repo.delete(setting.id)
|
||||
expect(repo.get(setting.id)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { SnapshotRepository } from '../../src/main/db/repositories/snapshot.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('SnapshotRepository', () => {
|
||||
let db: SqliteDb
|
||||
let repo: SnapshotRepository
|
||||
let chapterId: string
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
repo = new SnapshotRepository(db)
|
||||
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0, '初始内容').id
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('creates and lists snapshots', () => {
|
||||
repo.create(chapterId, 'manual', '版本一', '手动保存')
|
||||
expect(repo.list(chapterId)).toHaveLength(1)
|
||||
expect(repo.list(chapterId)[0].name).toBe('手动保存')
|
||||
})
|
||||
|
||||
it('prunes oldest auto snapshots', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
repo.create(chapterId, 'auto', `content-${i}`)
|
||||
}
|
||||
repo.pruneAuto(chapterId, 3)
|
||||
const autos = repo.list(chapterId).filter((s) => s.type === 'auto')
|
||||
expect(autos).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('restores snapshot content', () => {
|
||||
const snap = repo.create(chapterId, 'manual', '可恢复内容')
|
||||
expect(repo.restore(snap.id)).toBe('可恢复内容')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { wordFreqService } from '../../src/main/services/wordfreq.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('WordFreqService', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||
new ChapterRepository(db).create(
|
||||
vol.id,
|
||||
'第一章',
|
||||
0,
|
||||
'<p>林远走进青云山。The hero walked slowly.</p>'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('counts CJK characters and latin words', () => {
|
||||
const result = wordFreqService.analyze(db, { kind: 'book' })
|
||||
expect(result.words.length).toBeGreaterThan(0)
|
||||
const tokens = result.words.map((w) => w.token)
|
||||
expect(tokens).toContain('林')
|
||||
expect(tokens).toContain('the')
|
||||
})
|
||||
|
||||
it('analyzes single chapter scope', () => {
|
||||
const ch = (db.prepare('SELECT id FROM chapters LIMIT 1').get() as { id: string }).id
|
||||
const result = wordFreqService.analyze(db, { kind: 'chapter', chapterId: ch })
|
||||
expect(result.words.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user