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 } { const files = new Map() 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') assert.equal(r.text, '好的喔!') assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 }) }) test('格式錯誤時移除標記且無 delta', () => { const r = parseMoodTag('好的喔!') 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('嗯喔') 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('嗚') 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看完了') 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}`) 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('逐字保持原樣')) }) })