import { describe, expect, test } from 'claude-code/testing' import { createCompanion } from './core' import { applyEvent, baselineOf, circadian, decayEmotions, dominant, fromPad, halfLifeMs, toPad } from './emotion' import type { Emotions } from './emotion' import { fakeClock, memoryFs } from './fs' import type { CompanionCore } from './types' const H = 3_600_000 const near = (a: number, b: number) => expect(Math.abs(a - b)).toBeLessThan(1e-9) const ZERO = baselineOf({ p: 0, a: 0, d: 0 }) // 本地正午 12:00(tz +0000),晝夜節律不為 0 但不影響其他維度 const NOON = Date.UTC(2026, 9, 14, 12) const MIKO: CompanionCore = { id: 'miko', name: '月見ミコ', facts: { birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown', face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown' }, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 }, } describe('衰減', () => { test('各維度依自己的半衰期回到基線', () => { const s = { ...ZERO, shy: 1, homesick: 1, mood: 1, at: NOON } const e = decayEmotions(s, ZERO, NOON + 2 * H, 50, 0) near(e.shy, 0.5) // 害羞半衰期 2 小時 near(e.homesick, Math.pow(0.5, 2 / 24)) // 想家半衰期 24 小時 near(e.mood, 0.5 + 0.5 * Math.pow(0.5, 2 / 8)) }) test('神經質影響半衰期,且久放會回到基線', () => { expect(halfLifeMs('mood', 100)).toBeGreaterThan(halfLifeMs('mood', 0)) const e = decayEmotions({ ...ZERO, upset: 1, at: NOON }, ZERO, NOON + 30 * 24 * H, 50, 0) expect(e.upset).toBeLessThan(0.001) }) test('精力基線隨晝夜節律:下午高、凌晨低', () => { const at = (h: number) => decayEmotions({ ...ZERO, at: 0 }, ZERO, Date.UTC(2026, 9, 14, h) + 365 * 24 * H, 50, 0).energy expect(at(15)).toBeGreaterThan(at(3)) near(circadian(Date.UTC(2026, 9, 14, 15), 0), 0.1) near(circadian(Date.UTC(2026, 9, 14, 7), 480), 0.1) // 時區換算:UTC 07 = 本地 15 }) }) describe('習慣化與敏感化', () => { test('正向事件重複發生,效果遞減', () => { let e: Emotions = ZERO let habit = {} const gains: number[] = [] for (let i = 0; i < 3; i++) { const r = applyEvent(e, habit, 'thanks', NOON) gains.push(r.emotions.mood - e.mood) ;({ emotions: e, habit } = r) } near(gains[0]!, 0.075) near(gains[1]!, 0.075 * 0.7) near(gains[2]!, 0.075 * 0.49) }) test('習慣化隨時間遺忘:隔 6 小時後計次減半', () => { const r1 = applyEvent(ZERO, {}, 'thanks', NOON) const r2 = applyEvent(ZERO, r1.habit, 'thanks', NOON + 6 * H) near(r2.emotions.mood - ZERO.mood, 0.075 * Math.pow(0.7, 0.5)) }) test('負向事件重複發生,傷害放大且上限 2 倍', () => { let e: Emotions = { ...ZERO, mood: 1, energy: 0.6 } let habit = {} const drops: number[] = [] for (let i = 0; i < 6; i++) { const r = applyEvent(e, habit, 'scold', NOON) drops.push(e.mood - r.emotions.mood) ;({ emotions: e, habit } = r) } near(drops[0]!, 0.06) near(drops[1]!, 0.06 * 1.25) near(drops[5]!, 0.06 * 2) }) test('疲勞易怒:精力低時負面變化放大 1.5 倍', () => { const fresh = applyEvent({ ...ZERO, energy: 0.6 }, {}, 'toolFail', NOON).emotions const tired = applyEvent({ ...ZERO, energy: 0.1 }, {}, 'toolFail', NOON).emotions near(tired.upset - ZERO.upset, (fresh.upset - ZERO.upset) * 1.5) }) test('情感注入:感謝帶動心跳,想念會在回來時消解', () => { expect(applyEvent(ZERO, {}, 'thanks', NOON).emotions.heartbeat).toBeGreaterThan(0) const missing = applyEvent(ZERO, {}, 'ignored', NOON).emotions expect(applyEvent(missing, {}, 'return', NOON).emotions.missing).toBeLessThan(missing.missing) }) test('每維度單回合變化不超過 0.3,值域 0~1', () => { const r = applyEvent({ ...ZERO, mood: 0.05 }, { scold: { n: 50, at: NOON } }, 'scold', NOON).emotions expect(r.mood).toBeGreaterThanOrEqual(0) expect(r.upset).toBeLessThanOrEqual(0.3) }) }) describe('主情緒', () => { test('強度達門檻才採用,否則回到 PAD 八分區標籤', () => { expect(dominant(ZERO)).toBe(null) expect(dominant({ ...ZERO, shy: 0.8 })?.label).toBe('害羞') expect(dominant({ ...ZERO, mood: 0.1 })?.label).toBe('低落') }) }) describe('舊 PAD 存檔升級', () => { test('PAD 映射到八維度並可還原', () => { const base = baselineOf({ p: 0.2, a: 0.1, d: 0.1 }) const e = fromPad({ p: -0.5, a: 0.4, d: -0.3, at: 7 }, base, { p: 0.2, a: 0.1, d: 0.1 }) expect(e.at).toBe(7) const pad = toPad(e, base, { p: 0.2, a: 0.1, d: 0.1 }) near(pad.p, -0.5) near(pad.a, 0.4) near(pad.d, -0.3) }) test('讀檔時自動升級並寫回,狀態不變', async () => { const fs = memoryFs() const clock = fakeClock() const root = '/h/.companion' const now = clock.now() const old = { mood: { p: 0.3, a: 0, d: 0, at: now }, affinity: { value: 0.1, stage: 0, dayKey: '', dayGain: 0, lastSeen: now }, todos: 0 } fs.files.set(`${root}/global.json`, JSON.stringify({ active: 'miko', activatedOnce: true })) fs.files.set(`${root}/companions/miko/core.json`, JSON.stringify(MIKO)) fs.files.set(`${root}/companions/miko/state.json`, JSON.stringify(old)) const c = createCompanion({ fs, clock, root, builtins: [MIKO] }) const v = (await c.view())! near(v.mood.p, 0.3) near(v.emotions.mood, 0.65) await c.moodEvent('thanks') const saved = JSON.parse(fs.files.get(`${root}/companions/miko/state.json`)!) expect(saved.mood).toBeUndefined() expect(saved.emotion.mood).toBeGreaterThan(0.65) }) }) describe('/status', () => { test('預設顯示前 3 強情緒,--all 顯示 8 維度', async () => { const fs = memoryFs() const c = createCompanion({ fs, clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] }) await c.ensureActivated() await c.moodEvent('scold') const brief = (await c.command('status')).split('\n').find(l => l.startsWith('情緒 '))! expect(brief.slice(4).split('、')).toHaveLength(3) const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b[')) expect(all).toHaveLength(8) }) })