Files
jsc-marketplace/plugins/hud/hooks/companion/care.test.ts
T
jiantw83andClaude Sonnet 5.5 ac33dcca3b feat: M4 主動關心、待辦與記憶鞏固核心模組
- care:事件式關心(冷卻、每日上限、靜音、回覆中/輸入中不彈出),toast 與注入兩種輸出,台詞依觸發 × 個性 × 好感階段
- todo:新增、完成、承諾事項與到期提醒
- memory:檢索評分、遺忘曲線、鞏固與反思、金鑰過濾
- 以假時鐘與記憶體檔案系統測試核心接縫

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:30:22 +08:00

242 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('交報告'))
})
})