Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/companion.test.ts
T
rootandClaude Sonnet 5.5 13d3678032 feat: companion 狀態列與 info 輸出對齊設計稿
- 狀態列的 PAD 條改為最強的兩種情緒(不含精力)
- info 短版只列名字、關係、前 3 強情緒、適應度與待辦
- info --all 補上 12 格情緒與關係長條、人格 18 參數三層值、世界與時間感,基本資訊移到這裡
- 版本號改為 0.0.4

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

549 lines
21 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 { dailyLimits, effective, gain, initialRelation, interact, SEVERITY, stageFor, trustLoss } from './relation'
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('關係四維度與階段', () => {
const rel = async (c: ReturnType<typeof setup>['c']) => (await c.view())!.relation
test('從 0 開始,同一天的增量受日限約束', async () => {
const { c } = setup()
await c.ensureActivated()
expect(await rel(c)).toEqual({ affection: 0, trust: 0, intimacy: 0, dependence: 0 })
for (let i = 0; i < 50; i++) await c.affinityEvent('meaningful')
const r = await rel(c)
const lim = dailyLimits()
expect(r.affection).toBeLessThanOrEqual(lim.affection + 1e-9)
expect(r.trust).toBeLessThanOrEqual(lim.trust + 1e-9)
expect(r.intimacy).toBeLessThanOrEqual(lim.intimacy + 1e-9)
expect(r.dependence).toBeLessThanOrEqual(lim.dependence + 1e-9)
expect(r.affection).toBeGreaterThan(lim.affection * 0.7)
})
test('日限公式依人格傾向調整', () => {
expect(dailyLimits()).toEqual({ affection: 4, trust: 5 * 0.7, intimacy: 3.5 * 0.8 * 1.2, dependence: 2.5 * 1 })
expect(dailyLimits({ guard: 1 }).trust).toBeCloseTo(2)
expect(dailyLimits({ guard: 0 }).trust).toBe(5)
})
test('有效增量 = 原始增量 × (1 − 今日累計/日限) × (1 − 目前值/100)', () => {
let r = initialRelation(0)
r = gain(r, 'affection', 3)
expect(r.save.affection).toBeCloseTo(3)
r = gain(r, 'affection', 3)
expect(r.save.affection).toBeCloseTo(3 + 3 * (1 - 3 / 4) * (1 - 3 / 100))
const hi = { ...initialRelation(0), save: { affection: 50, trust: 0, intimacy: 0, dependence: 0 } }
expect(gain(hi, 'affection', 3).save.affection).toBeCloseTo(50 + 3 * 0.5)
})
test('隔天日限重置', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
const day1 = (await rel(c)).affection
clock.advance(DAY)
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
expect((await rel(c)).affection).toBeGreaterThan(day1 + 3)
})
test('期望違反:被罵扣好感 3,信賴扣得更多,越親密越多,單次上限 30', () => {
const r0 = { ...initialRelation(0), save: { affection: 20, trust: 40, intimacy: 0, dependence: 0 } }
const a = interact(r0, 'negative', 0)
expect(a.save.affection).toBeCloseTo(17)
expect(a.save.trust).toBeCloseTo(40 - 3 * 1.7)
const close = interact({ ...r0, save: { ...r0.save, intimacy: 100 } }, 'negative', 0)
expect(40 - close.save.trust).toBeGreaterThan(40 - a.save.trust)
expect(trustLoss(3, SEVERITY.lie, 100, 1)).toBeLessThanOrEqual(30)
expect(trustLoss(100, SEVERITY.lie, 100, 1)).toBe(30)
expect(trustLoss(3, SEVERITY.lie, 0)).toBeGreaterThan(trustLoss(3, SEVERITY.broken, 0))
expect(interact(initialRelation(0), 'negative', 0).save.trust).toBeCloseTo(0)
})
test('升階條件逐一檢查', () => {
const d = (affection: number, trust: number, intimacy: number) => ({ affection, trust, intimacy, dependence: 0 })
expect(stageFor(d(14, 99, 99), 0)).toBe(0)
expect(stageFor(d(15, 0, 0), 0)).toBe(1)
expect(stageFor(d(30, 24, 0), 0)).toBe(1)
expect(stageFor(d(30, 25, 0), 0)).toBe(2)
expect(stageFor(d(50, 45, 29), 0)).toBe(2)
expect(stageFor(d(50, 45, 30), 0)).toBe(3)
expect(stageFor(d(100, 100, 100), 0)).toBe(3) // 不實作曖昧以上
})
test('只升不降:數值回落、被罵都不降階', async () => {
const { c, clock } = setup()
await c.ensureActivated()
expect((await c.view())!.stage).toBe('陌生')
const seen = new Set<string>()
for (let d = 0; d < 60; d++) {
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
seen.add((await c.view())!.stage)
clock.advance(DAY)
}
expect([...seen]).toEqual(['陌生', '熟人', '朋友', '好友'])
for (let i = 0; i < 40; i++) await c.affinityEvent('negative')
expect((await rel(c)).trust).toBeLessThan(45)
expect((await c.view())!.stage).toBe('好友')
})
test('久未互動 14 天內不變,之後每天 −0.5,階段不降', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let d = 0; d < 20; d++) {
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
clock.advance(DAY)
}
clock.advance(-DAY)
const before = await rel(c)
const stage = (await c.view())!.stage
clock.advance(14 * DAY)
expect(await rel(c)).toEqual(before)
clock.advance(10 * DAY)
expect((await rel(c)).affection).toBeCloseTo(before.affection - 5)
expect((await rel(c)).trust).toBeCloseTo(before.trust - 5)
clock.advance(1000 * DAY)
expect((await rel(c)).affection).toBe(0)
expect((await c.view())!.stage).toBe(stage)
})
test('原典基準與存檔累積兩本帳相加', () => {
const base = { affection: 20, trust: 10, intimacy: 0, dependence: 0 }
const r = interact(initialRelation(0, base), 'meaningful', 0)
expect(effective(r).affection).toBeCloseTo(20 + 3 * (1 - 20 / 100))
expect(r.save.affection).toBeLessThan(3)
expect(r.stage).toBe(1)
})
})
describe('舊 affinity 存檔升級', () => {
const legacy = (value: number, stage: number, at: number) => ({
mood: { p: 0, a: 0, d: 0, at },
affinity: { value, stage, dayKey: '2026-10-14', dayGain: 0.02, lastSeen: at },
todos: 0,
})
const cases: [number, number, string][] = [
[0.05, 0, '陌生'],
[0.3, 1, '熟人'],
[0.55, 2, '朋友'],
[0.8, 3, '好友'],
]
for (const [value, stage, name] of cases) test(`value ${value}、stage ${stage} → ${name},好感值保留`, async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(value, stage, clock.now())))
const v = (await c.view())!
expect(v.stage).toBe(name)
expect(v.relation.affection).toBeCloseTo(value * 100)
const saved = JSON.parse((await fs.read(`${ROOT}/companions/miko/state.json`))!)
expect(saved.affinity).toBeUndefined()
expect(saved.relation.stage).toBe(stage)
expect(saved.relation.dayGain.affection).toBeCloseTo(2)
})
test('升級後可繼續互動', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(0.3, 1, clock.now())))
await c.affinityEvent('meaningful')
expect((await c.view())!.relation.affection).toBeGreaterThan(30)
})
})
describe('指令與設定', () => {
test('info --all 展開事實欄位與來源,查不到為 unknown', async () => {
const { c } = setup()
await c.ensureActivated()
const out = await c.command('status --all')
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 --all')
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((await c.view())!.relation.affection).toBeCloseTo(3)
await c.onPrompt('謝謝你')
expect((await c.view())!.mood.p).toBeGreaterThan(0.1)
const before = (await c.view())!.relation.affection
await c.onPrompt('你很煩')
expect((await c.view())!.relation.affection).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('你很煩,真的很煩人')
const path = `${ROOT}/companions/miko/memory.json`
const read = () => JSON.parse(fs.files.get(path)!) as { items: { negative?: boolean; resolvedAt?: number; status: string }[] }
expect(read().items[0]!.negative).toBe(true)
expect(read().items[0]!.resolvedAt).toBeUndefined()
await c.onPrompt('對不起,剛剛我太衝動了')
await c.onTurnComplete(false, '沒關係啦。<評估:apology>')
expect(read().items[0]!.resolvedAt !== undefined).toBe(true)
expect(read().items[0]!.status).toBe('active')
})
test('回覆中的提問成為開放迴路,2 回合沒回應就逾時', async () => {
const { c, fs } = setup()
await c.ensureActivated()
await c.onTurnComplete(false, '好喔。你今天吃飯了嗎?')
const path = `${ROOT}/companions/miko/memory.json`
const loops = () => (JSON.parse(fs.files.get(path)!) as { loops: { status: string }[] }).loops
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('幫我看看建置')
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('順便跑一下測試')
expect(loops()[0]!.status).toBe('timeout')
})
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([])
})
})