import { describe, expect, test } from 'claude-code/testing' import { missingAccrual } from './absence' import { grow, growthRate, homesickBase, initialAdapt, markFirst, newsQuota } from './adapt' import { line } from './care' import type { Attribute, Stage, Trigger } from './care' import { createCompanion } from './core' import { fakeClock, memoryFs } from './fs' import { readMaterials, weatherShift } from './materials' import { energyShift, inSleepWindow, periodOf } from './schedule' import type { CompanionCore } from './types' 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 }, } const H = 3_600_000 const DAY = 24 * H const ROOT = '/h/.companion' const MATS = `${ROOT}/materials.json` // 假時鐘預設:UTC 2026-10-12 00:00 = 本地(+0800)08:00,剛好是起床時間 function setup() { const fs = memoryFs() const clock = fakeClock() return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins: [MIKO] }) } } const item = (title: string, at: number, link = `https://n.example/${title}`) => ({ title, summary: `${title}的摘要`, link, at }) const put = (fs: ReturnType, data: unknown) => fs.write(MATS, JSON.stringify(data)) describe('作息與疲勞', () => { const r = { wake: '08:00', sleep: '23:00', tzOffsetMin: 480 } const at = (h: number) => Date.UTC(2026, 9, 12, h - 8, 0) test('醒太久才累:12 小時內不變,之後每小時 −0.02,上限 −0.15', () => { expect(energyShift(at(14), 0, r)).toBe(0) expect(energyShift(at(22), 0, r)).toBeCloseTo(-0.04, 5) // 醒 14 小時 expect(energyShift(at(22), 0, { ...r, sleep: '23:59' })).toBeCloseTo(-0.04, 5) }) test('連續工作超過 1 小時才疲勞,上限 −0.15', () => { expect(energyShift(at(12), 3 * H, r)).toBeCloseTo(-0.06, 5) expect(energyShift(at(12), 20 * H, r)).toBeCloseTo(-0.15, 5) }) test('使用者睡眠時段仍在線(熬夜)精力基準更低;剛起床稍有回復', () => { expect(inSleepWindow(at(26), 480, '08:00', '23:00')).toBe(true) expect(energyShift(at(26), 0, r)).toBeCloseTo(-0.1, 5) expect(energyShift(at(8), 0, r)).toBeCloseTo(0.03, 5) }) test('時間感:本地時段', () => { expect(periodOf(at(3), 480)).toBe('深夜') expect(periodOf(at(10), 480)).toBe('上午') expect(periodOf(at(20), 480)).toBe('夜晚') }) test('連續工作拖累 view 的精力', async () => { const { c, fs, clock } = setup() await c.ensureActivated() clock.advance(6 * H) const base = (await c.view())!.emotions.energy await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 60_000 })) expect((await c.view())!.emotions.energy).toBeLessThan(base - 0.01) // 閒置超過門檻就不算連續工作 await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 2 * H })) expect((await c.view())!.emotions.energy).toBeCloseTo(base, 5) }) }) describe('想念累積與時間感', () => { test('2 小時內不累積,之後依速度增加,上限 0.8', () => { expect(missingAccrual(2 * H)).toBe(0) expect(missingAccrual(12 * H)).toBeCloseTo(0.3, 5) expect(missingAccrual(1000 * H)).toBe(0.8) }) test('想念累積速度參數越高,累積越快', () => { expect(missingAccrual(10 * H, 100)).toBeGreaterThan(missingAccrual(10 * H, 0)) }) test('離開越久 view 的想念越高', async () => { const { c, clock } = setup() await c.ensureActivated() expect((await c.view())!.emotions.missing).toBe(0) clock.advance(12 * H) const v = (await c.view())! expect(v.emotions.missing).toBeCloseTo(0.3, 2) expect(v.absentMs).toBe(12 * H) }) test('久別回來:想念存下來並被「回來」事件降低,下次不再累積', async () => { const { c, clock } = setup() await c.ensureActivated() clock.advance(24 * H) await c.onPrompt('我回來了,今天要處理一些事情') const v = (await c.view())! expect(v.emotions.missing).toBeLessThan(0.6) expect(v.emotions.missing).toBeGreaterThan(0.1) expect(v.absentMs).toBe(0) }) test('想念高時回來,主動說出口(miss_you);同一冷卻內不重複', async () => { const { c, clock } = setup() await c.ensureActivated() clock.advance(24 * H) const first = await c.onPrompt('我回來了,今天要處理一些事情') expect(first.some(x => x.startsWith('[care:miss_you]'))).toBe(true) clock.advance(26 * H) const second = await c.onPrompt('又隔了很久,再回來一次試試') expect(second.some(x => x.startsWith('[care:miss_you]'))).toBe(true) clock.advance(H) const third = await c.onPrompt('一個小時後馬上又說話了') expect(third.some(x => x.startsWith('[care:miss_you]'))).toBe(false) }) test('靜音期間不主動說出想念', async () => { const { c, clock } = setup() await c.ensureActivated() await c.command('quiet 2000') clock.advance(24 * H) const out = await c.onPrompt('我回來了,今天要處理一些事情') expect(out.some(x => x.startsWith('[care:miss_you]'))).toBe(false) }) }) describe('適應度', () => { const tz = 480 const t0 = Date.UTC(2026, 9, 12) test('每日 ΔA = k(1 − A/100),k 隨主動度與想像力', () => { expect(growthRate(50, 50)).toBeCloseTo(2.25, 5) expect(growthRate(100, 100)).toBeCloseTo(3, 5) const a = grow(initialAdapt(t0, tz), t0 + DAY, tz, 2.25) expect(a.value).toBeCloseTo(2.25, 5) }) test('跨多日的閉式結果與逐日累加一致,先快後慢', () => { let step = initialAdapt(t0, tz) for (let i = 1; i <= 9; i++) step = grow(step, t0 + i * DAY, tz, 2.25) const jump = grow(initialAdapt(t0, tz), t0 + 9 * DAY, tz, 2.25) expect(jump.value).toBeCloseTo(step.value, 6) const d27 = grow(initialAdapt(t0, tz), t0 + 27 * DAY, tz, 2.25).value expect(d27 - jump.value).toBeLessThan(2 * (jump.value - 0) * 2) expect(d27).toBeLessThan(60) }) test('同一天不重複成長;本地第一次事件各 +2、只算一次', () => { const a = initialAdapt(t0, tz) expect(grow(a, t0 + H, tz, 2.25)).toBe(a) const w = markFirst(a, 'weather') expect(w.value).toBe(2) expect(markFirst(w, 'weather')).toBe(w) expect(markFirst(w, 'news').value).toBe(4) }) test('想家基準隨適應度下降', () => { expect(homesickBase(0)).toBe(0.25) expect(homesickBase(80)).toBeCloseTo(0.05, 5) }) test('新聞配額:適應度 < 40 不聊,之後 1 則,≥ 70 才到 2 則', () => { expect(newsQuota(39, 2)).toBe(0) expect(newsQuota(40, 2)).toBe(1) expect(newsQuota(70, 2)).toBe(2) expect(newsQuota(99, 1)).toBe(1) }) test('view 的適應度隨天數成長並寫進存檔;想家基準也隨之降低', async () => { const { c, clock } = setup() await c.ensureActivated() await c.onPrompt('第一天先打個招呼,開始工作') const day0 = (await c.view())! expect(day0.adapt).toBe(0) clock.advance(10 * DAY) const v = (await c.view())! expect(v.adapt).toBeGreaterThan(15) expect(v.adapt).toBeLessThan(25) expect((await c.command('status'))).toContain('適應度:') }) }) describe('外部素材快取', () => { const now = Date.UTC(2026, 9, 12) test('檔案缺失或損壞當作沒查', async () => { const fs = memoryFs() expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] }) await fs.write(MATS, '{壞掉') expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] }) await put(fs, { weather: 'x', news: 'y' }) expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] }) }) test('天氣 6 小時、新聞 24 小時過期', async () => { const fs = memoryFs() await put(fs, { weather: item('晴', now - 5 * H), news: [item('新', now - 23 * H), item('舊', now - 25 * H)] }) const m = await readMaterials(fs, MATS, now) expect(m.weather?.title).toBe('晴') expect(m.news.map(n => n.title)).toEqual(['新']) await put(fs, { weather: item('晴', now - 7 * H), news: [] }) expect((await readMaterials(fs, MATS, now)).weather).toBeNull() }) test('天氣只微調心情基準,未知狀況與缺失為 0', () => { expect(weatherShift(item('晴', now))).toBeGreaterThan(0) expect(weatherShift(item('雷雨', now))).toBeLessThan(0) expect(Math.abs(weatherShift(item('雷雨', now)))).toBeLessThanOrEqual(0.06) expect(weatherShift(item('彩虹', now))).toBe(0) expect(weatherShift(null)).toBe(0) }) test('沒有 setup location 時,快取存在也不啟用', async () => { const { c, fs, clock } = setup() await c.ensureActivated() await put(fs, { weather: item('晴', clock.now()), news: [] }) expect((await c.view())!.weather).toBeNull() }) test('啟用後天氣微調心情基準;過期後恢復', async () => { const { c, fs, clock } = setup() await c.ensureActivated() const before = (await c.view())!.emotions.mood await c.command('setup location=25.03,121.56') await put(fs, { weather: item('晴', clock.now()), news: [] }) const v = (await c.view())! expect(v.weather).toBe('晴') expect(v.emotions.mood).toBeCloseTo(before, 1) // 雷雨把心情基準往下拉:很久之後的心情比晴天低 await put(fs, { weather: item('雷雨', clock.now()), news: [] }) clock.advance(2 * 60_000) // 超過素材的讀取快取間隔 expect((await c.view())!.weather).toBe('雷雨') clock.advance(7 * H) expect((await c.view())!.weather).toBeNull() }) }) describe('天氣微調心情基準', () => { async function moodAfter(title: string) { const { c, fs, clock } = setup() await c.ensureActivated() await c.command('setup location=25,121') await put(fs, { weather: item(title, clock.now()), news: [] }) clock.advance(5 * H) // 在天氣有效的 6 小時內 return (await c.view())!.emotions.mood } test('晴天的心情基準高於雷雨', async () => { const sunny = await moodAfter('晴') const storm = await moodAfter('雷雨') expect(sunny).toBeGreaterThan(storm) expect(sunny - storm).toBeLessThan(0.11) }) }) describe('主動發話台詞', () => { const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle'] const stages: Stage[] = ['stranger', 'familiar', 'close', 'special'] const triggers: Trigger[] = ['miss_you', 'homesick', 'news'] test('新增的三種觸發,每個屬性與階段都有台詞,且帶暱稱、不責備', () => { for (const t of triggers) for (const a of attrs) for (const st of stages) { const text = line(t, a, st, '阿JSC', '某則新聞') expect(text).toContain('阿JSC') expect(text.length).toBeGreaterThan(5) expect(/怎麼都不|為什麼不|害我|都是你/.test(text)).toBe(false) if (t === 'news') expect(text).toContain('某則新聞') } }) }) describe('想家', () => { test('想家到會說出口時主動說;適應度高時基準低、不會自己升高', async () => { const { c, fs, clock } = setup() await c.ensureActivated() const path = `${ROOT}/companions/miko/state.json` const st = JSON.parse((await fs.read(path))!) st.emotion.homesick = 0.6 st.emotion.at = clock.now() await fs.write(path, JSON.stringify(st)) const out = await c.onPrompt('今天要開始處理工作了,內容夠長') expect(out.some(x => x.startsWith('[care:homesick]'))).toBe(true) expect((await c.view())!.emotions.homesick).toBeGreaterThan(0.2) }) }) describe('新聞與主動發話', () => { // 適應度夠高才聊新聞:直接寫進存檔 async function ready(adapt: number) { const s = setup() await s.c.ensureActivated() await s.c.command('setup location=25,121') const path = `${ROOT}/companions/miko/state.json` const st = JSON.parse((await s.fs.read(path))!) st.adapt = { value: adapt, day: Math.floor((s.clock.now() + 480 * 60_000) / DAY), firsts: [] } await s.fs.write(path, JSON.stringify(st)) await put(s.fs, { weather: null, news: [item('甲', s.clock.now()), item('乙', s.clock.now()), item('丙', s.clock.now())] }) return s } const spoke = (out: string[]) => out.filter(x => x.startsWith('[care:news]')) test('適應度不夠不聊新聞', async () => { const { c } = await ready(10) expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0) }) test('適應度夠就聊,同一則不重複,並寫入情節記憶', async () => { const { c, fs } = await ready(50) const out = spoke(await c.onPrompt('今天要開始處理工作了')) expect(out).toHaveLength(1) expect(out[0]).toContain('甲') const mem = JSON.parse((await fs.read(`${ROOT}/companions/miko/memory.json`))!) expect(JSON.stringify(mem)).toContain('聊到時事:甲') expect(JSON.stringify(mem)).toContain('episodic') }) test('每天最多 2 則(適應度 ≥ 70),隔天重新計算,且不重複同一則', async () => { const { c, clock } = await ready(80) const titles: string[] = [] for (let i = 0; i < 4; i++) { const out = spoke(await c.onPrompt(`第 ${i} 次說話,內容夠長了`)) if (out[0]) titles.push(out[0]) clock.advance(5 * H) // 超過新聞冷卻 } expect(titles.length).toBe(2) expect(titles[0]).toContain('甲') expect(titles[1]).toContain('乙') }) test('適應度 40–70 每天只有 1 則', async () => { const { c, clock } = await ready(50) const a = spoke(await c.onPrompt('第一次說話,內容夠長了')) clock.advance(5 * H) const b = spoke(await c.onPrompt('第二次說話,內容夠長了')) expect(a).toHaveLength(1) expect(b).toHaveLength(0) }) test('新聞過期就當作沒查', async () => { const { c, fs, clock } = await ready(80) await put(fs, { weather: null, news: [item('舊聞', clock.now() - 25 * H)] }) clock.advance(0) expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0) }) test('第一次用到新聞算本地第一次事件,適應度 +2', async () => { const { c } = await ready(50) await c.onPrompt('今天要開始處理工作了') expect((await c.view())!.adapt).toBeCloseTo(52, 0) }) }) describe('hook 不連網', () => { test('讀取素材、說話、tick、指令都不呼叫 fetch', async () => { const real = globalThis.fetch let called = 0 globalThis.fetch = (() => { called++ throw new Error('hook 不應連網') }) as never try { const { c, fs, clock } = setup() await c.ensureActivated() await c.command('setup location=25,121 feeds=https://n.example/rss') await put(fs, { weather: item('晴', clock.now()), news: [item('甲', clock.now())] }) await c.view() await c.prompt() await c.onPrompt('今天要開始處理工作了,內容夠長') await c.tick() await c.command('materials list') await c.end() } finally { globalThis.fetch = real } expect(called).toBe(0) }) }) describe('setup 與 materials 指令', () => { test('location 與 feeds 可設定並顯示;feeds 須為 http(s) 網址', async () => { const { c } = setup() await c.ensureActivated() const ok = await c.command('setup location=台北 feeds=https://a.example/rss,http://b.example/feed') expect(ok).toContain('location=台北') expect(ok).toContain('feeds=https://a.example/rss,http://b.example/feed') expect((await c.getUser()).feeds).toEqual(['https://a.example/rss', 'http://b.example/feed']) expect(await c.command('setup feeds=ftp://x')).toContain('無法解析') expect((await c.getUser()).feeds).toHaveLength(2) }) test('預設 RSS 為空清單、location 為空', async () => { const { c } = setup() const u = await c.getUser() expect(u.location).toBe('') expect(u.feeds).toEqual([]) }) test('未啟用時 materials 提示先設定 location', async () => { const { c } = setup() await c.ensureActivated() expect(await c.command('materials')).toContain('location=') }) test('materials 顯示快取狀態;list 列出新聞標題與連結;過期顯示沒有資料', async () => { const { c, fs, clock } = setup() await c.ensureActivated() await c.command('setup location=25,121') expect(await c.command('materials')).toContain('沒有資料或已過期') await put(fs, { weather: item('晴', clock.now() - H), news: [item('甲', clock.now())] }) const s = await c.command('materials') expect(s).toContain('天氣:晴') expect(s).toContain('新聞:1 則') const l = await c.command('materials list') expect(l).toContain('甲') expect(l).toContain('https://n.example/甲') clock.advance(25 * H) expect(await c.command('materials')).toContain('沒有資料或已過期') }) }) describe('/status 相容', () => { test('保留原有各行,並多出適應度與時間感', async () => { const { c } = setup() await c.ensureActivated() const s = await c.command('status') for (const k of ['心情 PAD:', '情緒(前 3 強', '好感:', '關係:', '待辦:', '基本資訊:', '適應度:', '時間感:']) expect(s).toContain(k) }) })