- care:事件式關心(冷卻、每日上限、靜音、回覆中/輸入中不彈出),toast 與注入兩種輸出,台詞依觸發 × 個性 × 好感階段 - todo:新增、完成、承諾事項與到期提醒 - memory:檢索評分、遺忘曲線、鞏固與反思、金鑰過濾 - 以假時鐘與記憶體檔案系統測試核心接縫 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
242 lines
9.9 KiB
TypeScript
242 lines
9.9 KiB
TypeScript
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<CareProfile>; settings?: Partial<CareSettings>; 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<CareProfile>, 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<string>()
|
||
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('交報告'))
|
||
})
|
||
})
|