import { describe, expect, test } from 'claude-code/testing' import { createCompanion } from './core' import { fakeClock, memoryFs } from './fs' import type { CompanionCore } from './types' // 測試用原創範例角色,不依賴網路或受版權保護的資料 const MIKO: CompanionCore = { id: 'miko', name: '月見ミコ', facts: { birthday: { value: '3/3', source: 'test', kind: 'novel', confidence: 1 }, zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown', face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown', }, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 }, } const H = 3_600_000 const DAY = 24 * H const ROOT = '/h/.companion' function setup(builtins = [MIKO]) { const fs = memoryFs() const clock = fakeClock() return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins }) } } describe('啟用', () => { test('第一次啟用自動啟用預設角色,之後不再', async () => { const { c } = setup() expect((await c.ensureActivated()).first).toBe(true) expect((await c.view())?.core.name).toBe('月見ミコ') expect((await c.ensureActivated()).first).toBe(false) }) test('off 後沒有角色,且重新啟動不會自動再啟用', async () => { const { c } = setup() await c.ensureActivated() await c.command('off') expect(await c.view()).toBe(null) await c.ensureActivated() expect(await c.view()).toBe(null) expect(await c.command('status')).toBe('目前沒有啟用角色。') }) test('切換角色保留原角色資料', async () => { const other = { ...MIKO, id: 'ren', name: '蓮' } const { c } = setup([MIKO, other]) await c.ensureActivated() await c.moodEvent('thanks') const before = (await c.view())!.mood expect(await c.command('switch ren')).toContain('蓮') expect((await c.view())?.core.id).toBe('ren') await c.command('switch miko') expect((await c.view())!.mood).toEqual(before) expect(await c.command('switch nobody')).toContain('找不到') }) }) describe('原子寫入', () => { test('不留暫存檔', async () => { const { c, fs } = setup() await c.ensureActivated() await c.moodEvent('thanks') expect([...fs.files.keys()].some(k => k.endsWith('.tmp'))).toBe(false) }) test('寫到一半當機時舊資料不損毀', async () => { const { c, fs } = setup() await c.ensureActivated() const path = `${ROOT}/companions/miko/state.json` const old = fs.files.get(path) const real = fs.rename fs.rename = async () => { throw new Error('crash') } await expect(c.moodEvent('thanks')).rejects.toThrow() fs.rename = real expect(fs.files.get(path)).toBe(old) }) }) describe('心情', () => { test('事件更新 PAD 並對應標籤', async () => { const { c } = setup() await c.ensureActivated() await c.moodEvent('thanks') const v = (await c.view())! expect(Math.abs(v.mood.p - (0.15))).toBeLessThan(1e-9) expect(v.label).toBe('興奮') }) test('衰減依經過時間回到基線,半衰期 8 小時', async () => { const { c, clock } = setup() await c.ensureActivated() await c.moodEvent('thanks') clock.advance(8 * H) expect(Math.abs((await c.view())!.mood.p - (0.075))).toBeLessThan(1e-9) clock.advance(10 * DAY) expect(Math.abs((await c.view())!.mood.p)).toBeLessThan(0.001) }) test('高神經質衰減較慢', async () => { const nervous = { ...MIKO, neuroticism: 100 } const { c, clock } = setup([nervous]) await c.ensureActivated() await c.moodEvent('thanks') clock.advance(8 * H) expect((await c.view())!.mood.p).toBeGreaterThan(0.09) }) test('單軸每回合變化不超過 0.3,且不超出 ±1', async () => { const { c } = setup() await c.ensureActivated() for (let i = 0; i < 10; i++) await c.moodEvent('scold') expect((await c.view())!.mood.p).toBe(-1) const { c: c2 } = setup() await c2.ensureActivated() await c2.moodEvent('scold') expect(Math.abs((await c2.view())!.mood.p - (-0.2))).toBeLessThan(1e-9) }) test('八分區標籤', async () => { const { c } = setup() await c.ensureActivated() await c.moodEvent('scold') expect((await c.view())!.label).toBe('焦慮') await c.moodEvent('lateNight') expect((await c.view())!.label).toBeTruthy() }) }) describe('好感度', () => { test('從 0 開始,每次 +0.01,每天上限 +0.05', async () => { const { c } = setup() await c.ensureActivated() expect((await c.view())!.affinity).toBe(0) for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) }) test('隔天可繼續累積;負面事件 −0.03', async () => { const { c, clock } = setup() await c.ensureActivated() for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful') clock.advance(DAY) await c.affinityEvent('meaningful') expect(Math.abs((await c.view())!.affinity - (0.06))).toBeLessThan(1e-9) await c.affinityEvent('negative') expect(Math.abs((await c.view())!.affinity - (0.03))).toBeLessThan(1e-9) }) test('離開超過 7 天後緩慢回到 0', async () => { const { c, clock } = setup() await c.ensureActivated() for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') clock.advance(7 * DAY) expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) clock.advance(30 * DAY) expect(Math.abs((await c.view())!.affinity - (0.025))).toBeLessThan(1e-9) }) test('階段切換有遲滯', async () => { const { c, clock } = setup() await c.ensureActivated() expect((await c.view())!.stage).toBe('陌生') for (let d = 0; d < 6; d++) { for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') clock.advance(DAY) } expect(Math.abs((await c.view())!.affinity - (0.3))).toBeLessThan(1e-9) expect((await c.view())!.stage).toBe('熟悉') await c.affinityEvent('negative') // 0.27,仍在遲滯區間 expect((await c.view())!.stage).toBe('熟悉') await c.affinityEvent('negative') // 0.24,低於降階門檻 0.25 expect((await c.view())!.stage).toBe('陌生') }) }) describe('指令與設定', () => { test('status 展開事實欄位與來源,查不到為 unknown', async () => { const { c } = setup() await c.ensureActivated() const out = await c.command('status') expect(out).toContain('月見ミコ') expect(out).toContain('birthday:3/3(novel,信心 1,test)') expect(out).toContain('bloodType:unknown') }) test('setup 寫入共用 user.json,換角色不受影響', async () => { const { c, fs } = setup() await c.ensureActivated() expect(await c.command('setup nickname=阿建 careLimit=3 quietStart=13:00 quietEnd=14:00')).toContain('設定已儲存') const u = JSON.parse(fs.files.get(`${ROOT}/user.json`)!) expect(u.nickname).toBe('阿建') expect(u.careLimit).toBe(3) expect(u.quiet).toEqual({ start: '13:00', end: '14:00' }) expect(await c.command('setup bogus=1')).toContain('無法解析') expect((await c.getUser()).nickname).toBe('阿建') }) test('預設角色白上吹雪的事實帶來源', async () => { const fs = memoryFs() const c = createCompanion({ fs, clock: fakeClock(), root: ROOT }) await c.ensureActivated() const out = await c.command('status') expect(out).toContain('白上吹雪') expect(out).toContain('hololivepro.com') expect(out).toContain('weight:unknown') }) }) describe('語氣注入', () => { const MIKO_TONE: CompanionCore = { ...MIKO, persona: ['句尾常加「にゃ」'], phrases: ['おはミコ'] } test('prompt 含角色名、稱呼、個性、招牌短語與技術內容保留規則', async () => { const { c } = setup([MIKO_TONE]) await c.ensureActivated() await c.command('setup nickname=阿建') const p = (await c.prompt())! expect(p).toContain('月見ミコ') expect(p).toContain('阿建') expect(p).toContain('にゃ') expect(p).toContain('おはミコ') expect(p).toContain('逐字保持原樣') }) test('角色關閉時沒有 prompt', async () => { const { c } = setup() await c.ensureActivated() await c.command('off') expect(await c.prompt()).toBeNull() }) test('送出提示:責罵降心情與好感,感謝升心情,普通互動累積好感', async () => { const { c } = setup() await c.ensureActivated() await c.onPrompt('請幫我看一下這個函式') expect(Math.abs((await c.view())!.affinity - 0.01)).toBeLessThan(1e-9) await c.onPrompt('謝謝你') expect((await c.view())!.mood.p).toBeGreaterThan(0.1) const before = (await c.view())!.affinity await c.onPrompt('你很煩') expect((await c.view())!.affinity).toBeLessThan(before) expect((await c.view())!.mood.p).toBeLessThan(0.1) }) }) describe('記憶、待辦與關心', () => { test('提示寫入記憶,之後相關提示會檢索到並進入 prompt', async () => { const { c } = setup() await c.ensureActivated() await c.onPrompt('我正在重構 payment 模組的退款流程') await c.onPrompt('接著處理 payment 退款的測試') expect(await c.prompt()).toContain('payment') }) test('太短的提示不寫入記憶', async () => { const { c, fs } = setup() await c.ensureActivated() await c.onPrompt('好') expect(fs.files.get(`${ROOT}/companions/miko/memory.json`)).toBeUndefined() }) test('todo add/list/done,狀態列待辦數同步', async () => { const { c } = setup() await c.ensureActivated() expect(await c.command('todo add 寫 README')).toContain('#1') expect(await c.command('todo promise 週五前補測試')).toContain('【承諾】') expect((await c.view())!.todos).toBe(2) expect(await c.command('todo')).toContain('寫 README') expect(await c.prompt()).toContain('週五前補測試') expect(await c.command('todo done 1')).toContain('已完成') expect((await c.view())!.todos).toBe(1) expect(await c.command('todo done 99')).toContain('找不到') expect(await c.command('todo add')).toContain('用法') }) test('todo due 依使用者時區解析,到期後 tick 提醒一次', async () => { const { c, clock } = setup() await c.ensureActivated() await c.command('setup tz=+0800') // 2026-10-12 08:00 UTC = 16:00 +0800 await c.command('todo add 開會 due=2026-10-12T16:00') expect(await c.tick()).toEqual([]) clock.advance(8 * H + 60_000) const first = await c.tick() expect(first.some(x => x.trigger === 'todo_due' && x.text.includes('開會'))).toBe(true) expect((await c.tick()).some(x => x.trigger === 'todo_due')).toBe(false) }) test('連續工具失敗後,下一個提示附帶關心情境並降低心情', async () => { const { c } = setup() await c.ensureActivated() await c.onPrompt('先跑一下測試看看') const before = (await c.view())!.mood.p for (let i = 0; i < 3; i++) await c.onToolResult(false) expect((await c.view())!.mood.p).toBeLessThan(before) expect((await c.onPrompt('再試一次')).length).toBeGreaterThan(0) }) test('quiet 靜音期間不送出關心', async () => { const { c } = setup() await c.ensureActivated() expect(await c.command('quiet 30')).toContain('30') await c.onPrompt('先跑一下測試看看') for (let i = 0; i < 3; i++) await c.onToolResult(false) expect(await c.onPrompt('再試一次')).toEqual([]) expect(await c.command('quiet 0')).toContain('取消') expect(await c.command('quiet abc')).toContain('用法') }) test('replying 或 typing 時 tick 不彈出也不消耗待辦提醒', async () => { const { c, clock } = setup() await c.ensureActivated() await c.command('todo add 喝水 due=2026-10-12T00:00') clock.advance(H) expect(await c.tick({ replying: true })).toEqual([]) expect((await c.tick()).some(x => x.trigger === 'todo_due')).toBe(true) }) test('session 結束時可鞏固記憶且不拋錯', async () => { const { c } = setup() await c.ensureActivated() await c.onPrompt('我正在重構 payment 模組的退款流程') await c.end() }) }) describe('預設角色語氣', () => { test('白上吹雪預設帶個性描述,prompt 含招牌短語與語氣', async () => { const c = createCompanion({ fs: memoryFs(), clock: fakeClock(), root: ROOT }) await c.ensureActivated() const p = (await c.prompt())! expect(p).toContain('白上吹雪') expect(p).toContain('Good foxy day!') expect(p).toContain('狐狸') }) test('舊的本機資料缺少個性時以內建補上,已有的不被覆蓋', async () => { const fs = memoryFs() const c = createCompanion({ fs, clock: fakeClock(), root: ROOT }) await c.ensureActivated() const path = `${ROOT}/companions/fubuki/core.json` const core = JSON.parse(fs.files.get(path)!) delete core.persona fs.files.set(path, JSON.stringify(core)) expect(await c.prompt()).toContain('狐狸') fs.files.set(path, JSON.stringify({ ...core, persona: ['自訂個性'] })) const p = (await c.prompt())! expect(p).toContain('自訂個性') expect(p).not.toContain('狐狸') }) }) describe('工作事件反應', () => { test('同一個測試指令失敗後成功:toast 反應,心情變好', async () => { const { c, clock } = setup() await c.ensureActivated() await c.onPrompt('開始跑測試修 bug') await c.tick() clock.advance(10 * 60_000) await c.onToolResult(false, { command: 'bun test' }) const before = (await c.view())!.mood.p await c.onToolResult(true, { command: 'bun test' }) expect((await c.view())!.mood.p).toBeGreaterThan(before) expect((await c.tick()).map(x => x.trigger)).toEqual(['test_recovered']) }) test('沒有轉通過的成功不改變心情', async () => { const { c } = setup() await c.ensureActivated() const before = (await c.view())!.mood await c.onToolResult(true, { command: 'bun test' }) expect((await c.view())!.mood).toEqual(before) }) test('有用過工具的回合結束:toast 反應並提升心情;純聊天不反應', async () => { const { c, clock } = setup() await c.ensureActivated() await c.onPrompt('幫我整理一下目錄結構') await c.tick() clock.advance(10 * 60_000) await c.onTurnComplete(false) expect(await c.tick()).toEqual([]) const before = (await c.view())!.mood.p await c.onTurnComplete(true) expect((await c.view())!.mood.p).toBeGreaterThan(before) expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done']) }) test('沒有啟用角色時事件被忽略', async () => { const { c } = setup() await c.ensureActivated() await c.command('off') await c.onToolResult(true, { command: 'bun test' }) await c.onTurnComplete(true) expect(await c.tick()).toEqual([]) }) test('角色資料的 freq 會調整主動頻率', async () => { const quiet = { ...MIKO, freq: { turn_done: 0.001 } } const { c, clock } = setup([quiet]) await c.ensureActivated() await c.onPrompt('幫我整理一下目錄結構') await c.tick() clock.advance(10 * 60_000) await c.onTurnComplete(true) expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done']) clock.advance(2 * 3600_000) await c.onTurnComplete(true) expect(await c.tick()).toEqual([]) }) })