feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ArcService } from '../../src/main/services/arc.service'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('ArcService', () => {
|
||||
let db: SqliteDb
|
||||
let arc: ArcService
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
arc = new ArcService(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('UT-ARC-01: aggregates knowledge nodes for character', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapter = new ChapterRepository(db).create(vol.id, '第一章', 0)
|
||||
const setting = new SettingRepository(db).create('character', '林远')
|
||||
const knowledge = new KnowledgeRepository(db)
|
||||
knowledge.create({
|
||||
type: 'character',
|
||||
title: '林远突破',
|
||||
content: '林远在山顶突破境界',
|
||||
sourceChapterId: chapter.id,
|
||||
lastMentionChapterId: chapter.id,
|
||||
status: 'approved'
|
||||
})
|
||||
|
||||
const nodes = arc.getForCharacter(setting.id)
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.title).toBe('林远突破')
|
||||
expect(nodes[0]?.chapterTitle).toBe('第一章')
|
||||
})
|
||||
|
||||
it('UT-ARC-02: falls back to setting description when no knowledge', () => {
|
||||
const setting = new SettingRepository(db).create('character', '配角')
|
||||
new SettingRepository(db).update(setting.id, { description: '出身寒门的少年' })
|
||||
|
||||
const nodes = arc.getForCharacter(setting.id)
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.content).toContain('出身寒门')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { ComplianceService } from '../../src/main/services/compliance.service'
|
||||
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||
|
||||
describe('ComplianceService', () => {
|
||||
let dir: string
|
||||
let svc: ComplianceService
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'bilin-compliance-'))
|
||||
svc = new ComplianceService(new GlobalSettingsService(dir))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-COMP-01: scans built-in and custom words', () => {
|
||||
svc.setWords(['测试敏感词'])
|
||||
const result = svc.scanText('正文含违禁与测试敏感词内容')
|
||||
const words = result.matches.map((m) => m.word)
|
||||
expect(words).toContain('违禁')
|
||||
expect(words).toContain('测试敏感词')
|
||||
})
|
||||
|
||||
it('UT-COMP-02: applyReplacements replaces configured pairs', () => {
|
||||
const out = svc.applyReplacements('这里有违禁词', { 违禁: '***' })
|
||||
expect(out).toBe('这里有***词')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
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 v10', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-MIG-10: fresh database migrates to v10 with timeline_events', () => {
|
||||
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(10)
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='timeline_events'")
|
||||
.get()
|
||||
expect(table).toBeTruthy()
|
||||
const cols = db.prepare('PRAGMA table_info(timeline_events)').all() as Array<{ name: string }>
|
||||
expect(cols.some((c) => c.name === 'story_time')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(9)
|
||||
expect(version.v).toBe(10)
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(9)
|
||||
expect(version.v).toBe(10)
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
||||
.get()
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v8', () => {
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
const table = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v9', () => {
|
||||
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(9)
|
||||
expect(version.v).toBe(10)
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'")
|
||||
.get()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { TimelineService } from '../../src/main/services/timeline.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('TimelineService', () => {
|
||||
let db: SqliteDb
|
||||
let timeline: TimelineService
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
timeline = new TimelineService(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('UT-TL-01: lists chapter entries sorted by story time', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapters = new ChapterRepository(db)
|
||||
const ch1 = chapters.create(vol.id, '第一章', 0)
|
||||
chapters.update(ch1.id, { storyTime: '730' })
|
||||
const ch2 = chapters.create(vol.id, '第二章', 1)
|
||||
chapters.update(ch2.id, { storyTime: '750' })
|
||||
|
||||
const list = timeline.list()
|
||||
expect(list.filter((e) => e.kind === 'chapter')).toHaveLength(2)
|
||||
expect(list[0]?.title).toBe('第一章')
|
||||
expect(list[1]?.title).toBe('第二章')
|
||||
})
|
||||
|
||||
it('UT-TL-02: detects age regression conflict', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapters = new ChapterRepository(db)
|
||||
const settings = new SettingRepository(db)
|
||||
const hero = settings.create('character', '林远')
|
||||
settings.update(hero.id, { properties: { birthDate: '700' } })
|
||||
|
||||
const ch1 = chapters.create(vol.id, '第一章', 0)
|
||||
chapters.update(ch1.id, { storyTime: '730' })
|
||||
const ch2 = chapters.create(vol.id, '第二章', 1)
|
||||
chapters.update(ch2.id, { storyTime: '720' })
|
||||
|
||||
const conflicts = timeline.detectConflicts()
|
||||
expect(conflicts).toHaveLength(1)
|
||||
expect(conflicts[0]?.chapterTitle).toBe('第二章')
|
||||
expect(conflicts[0]?.message).toContain('林远')
|
||||
})
|
||||
|
||||
it('UT-TL-03: upserts manual timeline event', () => {
|
||||
const entry = timeline.upsertManual({ title: '大战', storyTime: '800' })
|
||||
expect(entry.kind).toBe('manual')
|
||||
expect(entry.title).toBe('大战')
|
||||
const list = timeline.list()
|
||||
expect(list.some((e) => e.id === entry.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user