Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/companion.test.ts
T
rootandClaude Sonnet 5.5 ea17f1cb2d refactor: jsc-mod 改名 jsc-mod-statusline、jsc-companion 改名 jsc-mod-companion,指令拆成獨立指令
- jsc-mod-statusline:/jsc-mod-statusline:enable、compact、disable
- jsc-mod-companion:/jsc-mod-companion:status、switch、disable、setup、todo、quiet
- 同步更新 plugin.json、marketplace.json 與各 README
- 補上 statusline band 測試缺少的 ui.render 底層處理

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 05:45:52 +00:00

421 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([])
})
})