import { describe, test } from 'node:test' import assert from 'node:assert/strict' import { memoryFs } from './io' import { COOLDOWN_MS, createCare, dailyLimit, 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('/cockpit 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('交報告')) }) })