import { describe, test } from 'claude-code/testing' import assert from './assert' import { memoryFs } from './io' import { COOLDOWN_MS, createCare, dailyLimit, frequency, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care' import { createTodos } from './todo' const MIN = 60_000 const H = 3600_000 // 假時鐘:UTC 2026-10-12 10:00,時區 +0 const T0 = Date.UTC(2026, 9, 12, 10, 0) function setup(over: { profile?: Partial; settings?: Partial; start?: number } = {}) { let now = over.start ?? T0 const fs = memoryFs() const profile: CareProfile = { attribute: 'tsundere', extraversion: 50, stage: 'familiar', nickname: '阿JSC', ...over.profile } const settings: CareSettings = { dailyCap: 10, tzOffsetMin: 0, ...over.settings } const care = createCare({ fs, clock: () => now, path: 'care.json', profile: () => profile, settings: () => settings }) return { care, fs, profile, settings, advance: (ms: number) => void (now += ms), set: (t: number) => void (now = t), now: () => now } } describe('關心:觸發', () => { test('會話開始以 toast 問候,且只問候一次', async () => { const s = setup() await s.care.onUserTurn() const out = await s.care.tick() assert.deepEqual(out.map(c => c.trigger), ['session_start']) assert.equal(out[0]!.delivery, 'toast') assert.ok(out[0]!.text.includes('阿JSC')) assert.deepEqual(await s.care.tick(), []) }) test('閒置後回來觸發 idle_return', async () => { const s = setup() await s.care.onUserTurn() await s.care.tick() s.advance(3 * H) await s.care.onUserTurn() assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['idle_return']) }) test('連續工作 2 小時,下次送出訊息時注入情境', async () => { const s = setup() await s.care.onUserTurn() for (let i = 0; i < 5; i++) { s.advance(20 * MIN) assert.deepEqual(await s.care.onUserTurn(), [], `第 ${i + 1} 次不該觸發`) } s.advance(20 * MIN) // 累計 120 分鐘 const inj = await s.care.onUserTurn() assert.equal(inj.length, 1) assert.match(inj[0]!, /long_work/) }) test('深夜仍在工作時注入情境', async () => { const s = setup({ start: Date.UTC(2026, 9, 12, 0, 30) }) await s.care.onUserTurn() s.advance(40 * MIN) // 01:10 const inj = await s.care.onUserTurn() assert.ok(inj.some(x => x.includes('late_night'))) }) test('時區影響深夜判斷', async () => { // UTC 17:10 在 +8 是 01:10;UTC 16:30 是 00:30(非深夜) const s = setup({ start: Date.UTC(2026, 9, 12, 16, 30), settings: { tzOffsetMin: 480 } }) await s.care.onUserTurn() s.advance(40 * MIN) assert.ok((await s.care.onUserTurn()).some(x => x.includes('late_night'))) }) test('連續工具失敗達 3 次才關心,成功會重置', async () => { const s = setup() await s.care.onUserTurn() await s.care.onToolResult(false) await s.care.onToolResult(false) await s.care.onToolResult(true) await s.care.onToolResult(false) s.advance(MIN) assert.deepEqual(await s.care.onUserTurn(), []) await s.care.onToolResult(false) await s.care.onToolResult(false) s.advance(MIN) const inj = await s.care.onUserTurn() assert.equal(inj.length, 1) assert.match(inj[0]!, /tool_failures/) }) test('額度警告與待辦到期以 toast 顯示', async () => { const s = setup() await s.care.onUserTurn() await s.care.tick() const out = await s.care.tick({ quotaWarning: true, dueTodo: '寄信' }) assert.deepEqual(out.map(c => c.trigger).sort(), ['quota_warning', 'todo_due']) assert.ok(out.find(c => c.trigger === 'todo_due')!.text.includes('寄信')) }) }) describe('關心:冷卻與上限', () => { test('同一種關心在冷卻內不重複,冷卻後恢復', async () => { const s = setup() await s.care.onUserTurn() await s.care.tick() assert.equal((await s.care.tick({ quotaWarning: true })).length, 1) s.advance(COOLDOWN_MS.quota_warning - 1) assert.equal((await s.care.tick({ quotaWarning: true })).length, 0) s.advance(1) assert.equal((await s.care.tick({ quotaWarning: true })).length, 1) }) test('每日總上限,隔天重算', async () => { const s = setup({ profile: { stage: 'stranger', extraversion: 0 }, settings: { dailyCap: 10 } }) const limit = dailyLimit(s.profile, s.settings) assert.equal(limit, 1) await s.care.onUserTurn() await s.care.tick() // session_start 用掉唯一一次 s.advance(2 * H) assert.deepEqual(await s.care.tick({ quotaWarning: true }), []) s.advance(24 * H) assert.equal((await s.care.tick({ quotaWarning: true })).length, 1) }) test('上限受好感階段與外向性影響,並被使用者設定封頂', () => { const p: CareProfile = { attribute: 'calm', extraversion: 50, stage: 'stranger', nickname: 'x' } const s: CareSettings = { dailyCap: 100, tzOffsetMin: 0 } const lim = (patch: Partial, cap = 100) => dailyLimit({ ...p, ...patch }, { ...s, dailyCap: cap }) assert.ok(lim({ stage: 'special' }) > lim({ stage: 'stranger' })) assert.ok(lim({ extraversion: 100 }) > lim({ extraversion: 0 })) assert.equal(lim({ stage: 'special', extraversion: 100 }, 3), 3) }) }) describe('關心:靜音與不打斷', () => { test('回覆中或輸入中不彈出,且不消耗冷卻與上限', async () => { const s = setup() await s.care.onUserTurn() assert.deepEqual(await s.care.tick({ replying: true }), []) assert.deepEqual(await s.care.tick({ typing: true }), []) assert.equal((await s.care.state()).count, 0) // 放開後,原本等待的問候仍會出現 assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start']) }) test('/companion quiet 在期間內靜音,到期後恢復', async () => { const s = setup() await s.care.quiet(30) await s.care.onUserTurn() assert.deepEqual(await s.care.tick({ quotaWarning: true }), []) s.advance(31 * MIN) // 靜音期間錯過的問候在恢復後補上 assert.deepEqual((await s.care.tick({ quotaWarning: true })).map(c => c.trigger).sort(), ['quota_warning', 'session_start']) }) test('quiet 0 取消靜音', async () => { const s = setup() await s.care.quiet(60) await s.care.quiet(0) await s.care.onUserTurn() assert.ok((await s.care.tick({ quotaWarning: true })).length > 0) }) test('靜音時段(跨午夜)內不關心,注入也不產生', async () => { const s = setup({ start: Date.UTC(2026, 9, 12, 23, 30), settings: { quietHours: { start: 23, end: 7 } } }) await s.care.onUserTurn() s.advance(3 * H) assert.deepEqual(await s.care.onUserTurn(), []) assert.deepEqual(await s.care.tick({ quotaWarning: true }), []) s.set(Date.UTC(2026, 9, 12, 8, 0) + 24 * H) assert.ok((await s.care.tick({ quotaWarning: true })).length > 0) }) test('狀態持久化:重建引擎後冷卻仍有效', async () => { const s = setup() await s.care.onUserTurn() await s.care.tick({ quotaWarning: true }) const again = createCare({ fs: s.fs, clock: s.now, path: 'care.json', profile: () => s.profile, settings: () => s.settings }) assert.deepEqual(await again.tick({ quotaWarning: true }), []) }) }) describe('關心:台詞', () => { const triggers: Trigger[] = ['session_start', 'idle_return', 'long_work', 'late_night', 'tool_failures', 'quota_warning', 'todo_due'] const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle'] const stages: Stage[] = ['stranger', 'familiar', 'close', 'special'] const BLAME = /(都怪你|你又|為什麼不|怎麼還不|太懶|不像話|再不.*就|我會難過|害我|對不起我|辜負|失望|沒用)/ test('每個 觸發 × 屬性 × 階段 都有台詞,帶暱稱,不含責備或愧疚勒索', () => { for (const t of triggers) for (const a of attrs) for (const s of stages) { const text = line(t, a, s, '小明', '報告') assert.ok(text.length > 0) assert.ok(text.includes('小明'), `${t}/${a}/${s} 缺暱稱`) assert.doesNotMatch(text, BLAME, `${t}/${a}/${s}: ${text}`) } }) test('同一觸發在不同屬性與階段下措辭不同', () => { const set = new Set() for (const a of attrs) for (const s of stages) set.add(line('late_night', a, s, 'x')) assert.equal(set.size, attrs.length * stages.length) }) }) describe('待辦', () => { test('新增、完成與未完成計數', async () => { let now = T0 const todos = createTodos(memoryFs(), () => now, 'todo.json') const a = await todos.add('寫報告') await todos.add('買牛奶') assert.equal(await todos.open(), 2) await todos.complete(a.id) assert.equal(await todos.open(), 1) assert.deepEqual((await todos.list()).map(t => t.title), ['買牛奶']) assert.equal((await todos.list({ all: true })).length, 2) assert.equal(await todos.complete(999), null) now += 1 }) test('承諾事項到期提醒一次,完成的不提醒', async () => { let now = T0 const todos = createTodos(memoryFs(), () => now, 'todo.json') await todos.add('三點開會', { promise: true, dueAt: T0 + H }) const done = await todos.add('已做完', { dueAt: T0 + H }) await todos.complete(done.id) assert.deepEqual(await todos.due(), []) now += H assert.deepEqual((await todos.due()).map(t => t.title), ['三點開會']) assert.deepEqual(await todos.due(), []) }) test('到期待辦餵給關心後產生 todo_due', async () => { const s = setup() const todos = createTodos(s.fs, s.now, 'todo.json') await todos.add('交報告', { promise: true, dueAt: T0 + 5 * MIN }) await s.care.onUserTurn() await s.care.tick() s.advance(6 * MIN) const due = await todos.due() const out = await s.care.tick({ dueTodo: due[0]?.title }) assert.deepEqual(out.map(c => c.trigger), ['todo_due']) assert.ok(out[0]!.text.includes('交報告')) }) }) describe('關心:工作事件反應', () => { async function started() { const s = setup() await s.care.onUserTurn() await s.care.tick() // 清掉開場問候 s.advance(10 * MIN) return s } test('同一個驗證指令先失敗後成功,tick 以 toast 反應', async () => { const s = await started() await s.care.onToolResult(false, 'bun test') await s.care.onToolResult(true, 'bun test') const out = await s.care.tick() assert.deepEqual(out.map(c => c.trigger), ['test_recovered']) assert.equal(out[0]!.delivery, 'toast') assert.ok(out[0]!.text.includes('阿JSC')) }) test('不同指令的失敗與成功不算轉通過', async () => { const s = await started() await s.care.onToolResult(false, 'bun test') await s.care.onToolResult(true, 'tsc -p .') assert.deepEqual(await s.care.tick(), []) }) test('非驗證指令的失敗後成功不反應', async () => { const s = await started() await s.care.onToolResult(false, 'grep foo src') await s.care.onToolResult(true, 'grep foo src') assert.deepEqual(await s.care.tick(), []) }) test('模型回覆中不彈出,之後仍會補上', async () => { const s = await started() await s.care.onToolResult(false, 'npm test') await s.care.onToolResult(true, 'npm test') assert.deepEqual(await s.care.tick({ replying: true }), []) assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['test_recovered']) }) }) describe('關心:一輪完成與疲乏', () => { // special 階段每日上限高,讓測試只觀察疲乏而非每日上限 // 抵銷屬性與外向性的頻率倍率,只留下疲乏的效果 const neutral = (p: Omit): CareProfile['freq'] => ({ turn_done: 1 / frequency({ ...p, nickname: '' }, 'turn_done') }) async function started(extraversion: number) { const base = { extraversion, stage: 'special', attribute: 'genki' } as const const s = setup({ profile: { ...base, freq: neutral(base) } }) await s.care.onUserTurn() await s.care.tick() s.advance(10 * MIN) const attempt = async () => { await s.care.onTurnComplete(true) return (await s.care.tick()).filter(c => c.trigger === 'turn_done').length } return { s, attempt } } test('沒有呼叫過工具的回合不算做完事', async () => { const { s } = await started(50) await s.care.onTurnComplete(false) assert.deepEqual(await s.care.tick(), []) }) test('有呼叫工具的回合結束,以 toast 反應', async () => { const { s } = await started(50) await s.care.onTurnComplete(true) const out = await s.care.tick() assert.deepEqual(out.map(c => c.trigger), ['turn_done']) assert.equal(out[0]!.delivery, 'toast') }) test('每反應一次,下次的冷卻就更長', async () => { const { s, attempt } = await started(10) // rate 1.4 → 第二次冷卻 20×2.4=48 分 assert.equal(await attempt(), 1) s.advance(25 * MIN) assert.equal(await attempt(), 0, '基礎冷卻 20 分已過,但疲乏使冷卻拉長') s.advance(25 * MIN) assert.equal(await attempt(), 1, '累計 50 分,過了拉長後的冷卻') }) test('內向角色疲乏到門檻就暫停,休息夠久後恢復', async () => { const { s, attempt } = await started(10) let fired = 0 for (let i = 0; i < 12; i++) { s.advance(30 * MIN) fired += await attempt() } assert.ok(fired <= 3, `6 小時內最多反應 3 次,實際 ${fired}`) assert.equal(await attempt(), 0, '仍在暫停中') s.advance(24 * H) assert.equal(await attempt(), 1, '休息一天後恢復') }) test('外向角色比內向角色更常反應', async () => { const count = async (ext: number) => { const { s, attempt } = await started(ext) let fired = 0 for (let i = 0; i < 16; i++) { s.advance(30 * MIN) fired += await attempt() } return fired } assert.ok((await count(100)) > (await count(10))) }) test('角色資料可覆寫疲乏參數', async () => { const base = { extraversion: 50, stage: 'special', attribute: 'genki' } as const const s = setup({ profile: { ...base, freq: neutral(base), fatigueRate: 0.1, fatigueCap: 99 } }) await s.care.onUserTurn() await s.care.tick() let fired = 0 for (let i = 0; i < 6; i++) { s.advance(30 * MIN) await s.care.onTurnComplete(true) fired += (await s.care.tick()).filter(c => c.trigger === 'turn_done').length } assert.ok(fired >= 5, `幾乎不疲乏,實際 ${fired}`) }) }) describe('關心:個性決定主動頻率', () => { const prof = (attribute: Attribute, extraversion = 50, freq?: CareProfile['freq']): CareProfile => ({ attribute, extraversion, stage: 'close', nickname: 'n', freq }) test('待辦到期與額度警告不受個性影響', () => { for (const t of ['todo_due', 'quota_warning'] as const) { for (const a of ['tsundere', 'genki', 'calm', 'gentle'] as const) assert.equal(frequency(prof(a, 10), t), 1, `${a}/${t}`) } }) test('genki 比 calm 更常主動', () => { for (const t of ['turn_done', 'long_work', 'session_start'] as const) assert.ok(frequency(prof('genki'), t) > frequency(prof('calm'), t), t) }) test('gentle 關心多、慶祝少', () => { const g = prof('gentle') assert.ok(frequency(g, 'late_night') > frequency(g, 'turn_done')) assert.ok(frequency(g, 'tool_failures') > frequency(g, 'test_recovered')) }) test('外向性放大頻率,內向縮小', () => { assert.ok(frequency(prof('calm', 90), 'long_work') > frequency(prof('calm', 10), 'long_work')) }) test('角色資料的倍率覆寫預設', () => { assert.equal(frequency(prof('calm', 50, { turn_done: 2 }), 'turn_done'), frequency(prof('calm'), 'turn_done') * 2) }) test('頻率倍率縮放冷卻:gentle 的連續失敗關心比 calm 更快再來', async () => { const run = async (attribute: Attribute) => { const s = setup({ profile: { attribute, stage: 'special' } }) await s.care.onUserTurn() const fail3 = async () => { for (let i = 0; i < 3; i++) await s.care.onToolResult(false) return (await s.care.onUserTurn()).length } s.advance(MIN) assert.equal(await fail3(), 1) s.advance(30 * MIN) // 基礎冷卻 30 分:gentle 約 23 分已過,calm 約 43 分未過 return fail3() } assert.equal(await run('gentle'), 1) assert.equal(await run('calm'), 0) }) test('每日上限隨屬性調整,且仍受使用者設定封頂', () => { const set = (dailyCap: number): CareSettings => ({ dailyCap, tzOffsetMin: 0 }) assert.ok(dailyLimit(prof('genki'), set(99)) > dailyLimit(prof('calm'), set(99))) assert.equal(dailyLimit(prof('genki', 100), set(2)), 2) }) })