160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
import assert from 'node:assert/strict'
|
||
import { describe, test } from 'node:test'
|
||
|
||
import { createCompanion } from './core.ts'
|
||
import type { Card } from './core.ts'
|
||
import { estimateTokens } from './inject.ts'
|
||
import { parseMoodTag } from './mood.ts'
|
||
import { redactSecrets } from './secrets.ts'
|
||
import type { Fs } from './types.ts'
|
||
|
||
// 記憶體檔案系統與假時鐘
|
||
function memFs(): Fs & { files: Map<string, string> } {
|
||
const files = new Map<string, string>()
|
||
return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) }
|
||
}
|
||
const T0 = Date.parse('2026-10-12T10:00:00Z')
|
||
function clock(t = T0) {
|
||
return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) }
|
||
}
|
||
|
||
// 原創範例角色
|
||
const CARD: Card = {
|
||
name: '小澄',
|
||
lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'],
|
||
}
|
||
const KEY = 'sk-abcdefghijklmnop1234567890'
|
||
|
||
async function setup() {
|
||
const fs = memFs()
|
||
const clk = clock()
|
||
fs.files.set('/c/sumi/card.json', JSON.stringify(CARD))
|
||
const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' })
|
||
return { fs, clk, c }
|
||
}
|
||
|
||
describe('心情標記', () => {
|
||
test('解析並移除末尾標記', () => {
|
||
const r = parseMoodTag('好的喔!\n<!--mood p=0.1 a=-0.05 d=0-->')
|
||
assert.equal(r.text, '好的喔!')
|
||
assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 })
|
||
})
|
||
test('格式錯誤時移除標記且無 delta', () => {
|
||
const r = parseMoodTag('好的喔!<!--mood p=abc-->')
|
||
assert.equal(r.text, '好的喔!')
|
||
assert.equal(r.delta, null)
|
||
})
|
||
test('缺失標記退回關鍵字規則', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('謝謝你,太棒了')
|
||
const shown = await c.assistantReply('不客氣喔')
|
||
assert.equal(shown, '不客氣喔')
|
||
assert.ok((await c.mood()).pad.p > 0)
|
||
})
|
||
test('標記有效時採用標記', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('隨便聊聊')
|
||
await c.assistantReply('嗯喔<!--mood p=-0.2 a=0 d=0-->')
|
||
assert.ok((await c.mood()).pad.p < 0)
|
||
})
|
||
})
|
||
|
||
describe('心情上限與事件', () => {
|
||
test('單軸每回合變化不超過 0.3', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('你很笨', ['scold', 'scold', 'scold'])
|
||
await c.assistantReply('嗚<!--mood p=-0.9 a=0.9 d=-0.9-->')
|
||
const { pad } = await c.mood()
|
||
assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9)
|
||
assert.ok(pad.p < 0)
|
||
})
|
||
test('事件表:稱讚提升愉悅', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('做得好', ['praise'])
|
||
assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9)
|
||
})
|
||
})
|
||
|
||
describe('好感度', () => {
|
||
test('有意義互動 +0.01,每日上限 +0.05', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('今天的進度如何')
|
||
assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9)
|
||
for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧')
|
||
assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9)
|
||
})
|
||
test('負面事件 −0.03', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('隨便', ['scold'])
|
||
assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9)
|
||
})
|
||
test('離開超過 7 天後回落', async () => {
|
||
const { c, clk } = await setup()
|
||
for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', [])
|
||
const before = (await c.affinity()).value
|
||
clk.advance(37 * 86_400_000)
|
||
assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9)
|
||
})
|
||
})
|
||
|
||
describe('互動記憶', () => {
|
||
test('與原著記憶分開存放,且只存對話文字', async () => {
|
||
const { c, fs } = await setup()
|
||
await c.userTurn('幫我看看這段')
|
||
await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了<!--mood p=0 a=0 d=0-->')
|
||
const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))!
|
||
assert.ok(key.startsWith('/c/sumi/interaction/'))
|
||
const stored = fs.files.get(key)!
|
||
assert.ok(!stored.includes('const a = 1'))
|
||
assert.ok(!stored.includes('mood'))
|
||
})
|
||
test('金鑰在寫入前被過濾', async () => {
|
||
const { c, fs } = await setup()
|
||
await c.userTurn(`我的金鑰是 ${KEY} 請記住`)
|
||
for (const v of fs.files.values()) assert.ok(!v.includes(KEY))
|
||
})
|
||
test('累積後壓縮成摘要', async () => {
|
||
const { c, fs } = await setup()
|
||
for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`)
|
||
const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!)
|
||
assert.ok(m.recent.length <= 30)
|
||
assert.ok(m.summary.length > 0)
|
||
})
|
||
})
|
||
|
||
describe('人設注入', () => {
|
||
test('角色卡在前、心情與記憶在後', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn('我喜歡紅茶')
|
||
const out = await c.buildInjection()
|
||
assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶'))
|
||
assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情'))
|
||
})
|
||
test('不含疑似金鑰與工具輸出', async () => {
|
||
const { c } = await setup()
|
||
await c.userTurn(`token=${KEY}`)
|
||
await c.assistantReply(`好的 ${KEY}<!--mood p=0 a=0 d=0-->`)
|
||
const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] })
|
||
assert.ok(!out.includes(KEY))
|
||
assert.equal(redactSecrets(out), out)
|
||
})
|
||
test('超出預算時截斷低分項目', async () => {
|
||
const { c } = await setup()
|
||
for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`)
|
||
const out = await c.buildInjection()
|
||
const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]!
|
||
assert.ok(estimateTokens(mem) <= 800)
|
||
assert.ok(mem.includes('編號 29'))
|
||
assert.ok(!mem.includes('編號 0,'))
|
||
assert.ok(estimateTokens(out) <= 1600)
|
||
})
|
||
test('tone off 時不注入語氣規則', async () => {
|
||
const { c } = await setup()
|
||
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
|
||
assert.equal(await c.command('tone off'), '語氣已關閉')
|
||
assert.ok(!(await c.buildInjection()).includes('逐字保持原樣'))
|
||
await c.command('tone on')
|
||
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
|
||
})
|
||
})
|