Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/care.test.ts
T
rootandClaude Sonnet 5.5 ea17f1cb2d refactor: jsc-mod 改名 jsc-mod-statusline、jsc-companion 改名 jsc-mod-companion,指令拆成獨立指令
- jsc-mod-statusline:/jsc-mod-statusline:enable、compact、disable
- jsc-mod-companion:/jsc-mod-companion:status、switch、disable、setup、todo、quiet
- 同步更新 plugin.json、marketplace.json 與各 README
- 補上 statusline band 測試缺少的 ui.render 底層處理

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 05:45:52 +00:00

416 lines
17 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 '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<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('/jsc-mod-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<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('交報告'))
})
})
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, 'nickname'>): 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)
})
})