Files
jsc-marketplace/plugins/cockpit/hooks/companion/companion.test.ts
T

218 lines
7.3 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/.cockpit'
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('companion off 後沒有角色,且重新啟動不會自動再啟用', async () => {
const { c } = setup()
await c.ensureActivated()
await c.command('companion 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(v.mood.p).toBeCloseTo(0.15)
expect(v.label).toBe('興奮')
})
test('衰減依經過時間回到基線,半衰期 8 小時', async () => {
const { c, clock } = setup()
await c.ensureActivated()
await c.moodEvent('thanks')
clock.advance(8 * H)
expect((await c.view())!.mood.p).toBeCloseTo(0.075)
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((await c2.view())!.mood.p).toBeCloseTo(-0.2)
})
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((await c.view())!.affinity).toBeCloseTo(0.05)
})
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((await c.view())!.affinity).toBeCloseTo(0.06)
await c.affinityEvent('negative')
expect((await c.view())!.affinity).toBeCloseTo(0.03)
})
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((await c.view())!.affinity).toBeCloseTo(0.05)
clock.advance(30 * DAY)
expect((await c.view())!.affinity).toBeCloseTo(0.025)
})
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((await c.view())!.affinity).toBeCloseTo(0.3)
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')
})
})