- 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>
416 lines
17 KiB
TypeScript
416 lines
17 KiB
TypeScript
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)
|
||
})
|
||
})
|