merge: 合併 origin/main(M0 cockpit 改名)並整合 companion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user