// 起居注的单测 —— 照 test/dynasties.test.js 的立意写: // 这不是「覆盖率」测试,这是【纪律的可执行定义】。 // // 起居注的纪律写在 services/acts.js 的注释里。但注释拦不住任何人 —— // decisions.js 早就说过这句:「规矩写在注释里没用,写成断言才算数」。 // 下面每一组断言都对应一条会把这个功能做废的错误: // 1. 记载分身干了它没干的事(跳过分支)→ 整本书从「实录」变「小说」 // 2. 截断失守 → 共享单机 99G 盘 → mongod 死 → 全站 24 应用连锁瘫 // 3. 挂不到 twinId 的记录 → 躲过 deleteTwin 级联删 → 合规漏洞 // 4. summarize 吐出可刷的分数 → 用户优化那个数 → 这本书照的不再是他,是他的 build // // 跑:node --test test/acts.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { ObjectId } from 'mongodb'; import { RESULT_MAX, ARG_MAX, ARGS_MAX_KEYS, MAX_ACTS_PER_TWIN, TRIGGERS, snapshotArgs, truncResult, buildActDoc, summarizeRows, buildActsQuery, ACTS_SORT, } from '../src/services/acts.js'; import { isRealCall } from '../src/services/tools.js'; const TWIN = new ObjectId().toString(); const OWNER = new ObjectId().toString(); // ══════════════════════════════════════════════════════════════════════════ // 0. ★ 翻页不许翻丢 —— 游标的键必须与排序的键一一对上 // // 排序 {createdAt:-1,_id:-1} 是复合的,游标就必须是复合的。 // 只按 createdAt 翻,同毫秒并列的那几条会被整条跳过,且【前端察觉不到】—— // 史官漏记朝会,与史官编造朝会(第 1 组)是同一种罪。 // ══════════════════════════════════════════════════════════════════════════ test('★ 翻页游标必须复合:只按 createdAt 会把同毫秒并列项整条跳过', () => { const t = new Date('2026-07-16T10:00:00.000Z'); const bid = new ObjectId(); // 带 beforeId:必须是「严格排在 (createdAt,_id) 这一对之后」的两支,一支都不许少。 const q = buildActsQuery(TWIN, { before: t.toISOString(), beforeId: bid.toString() }); assert.ok(Array.isArray(q.$or), '复合游标必须用 $or 两支,不能只写 createdAt:$lt'); assert.equal(q.$or.length, 2); // 第一支:更早的毫秒 assert.deepEqual(q.$or[0], { createdAt: { $lt: t } }); // 第二支:★ 同毫秒里、_id 更小的那些 —— 就是原先被整条跳过的那几条 assert.deepEqual(q.$or[1], { createdAt: t, _id: { $lt: bid } }); // 不许再有裸的 createdAt 顶层条件(会和 $or 互相打架) assert.equal(q.createdAt, undefined); // 排序键与游标键必须是同一把钥匙:排序里有几个字段,游标就得管几个字段。 assert.deepEqual(Object.keys(ACTS_SORT), ['createdAt', '_id']); }); test('翻页游标:没给 beforeId 时退回单字段(会丢并列项,但绝不重复);脏输入不炸', () => { const t = new Date('2026-07-16T10:00:00.000Z'); const q = buildActsQuery(TWIN, { before: t.toISOString() }); assert.deepEqual(q.$or, [{ createdAt: { $lt: t } }]); // 脏 beforeId 不许把整个游标弄没,也不许抛 const q2 = buildActsQuery(TWIN, { before: t.toISOString(), beforeId: '不是ObjectId' }); assert.deepEqual(q2.$or, [{ createdAt: { $lt: t } }]); // 脏 before:整个游标条件不加(当第一页),不抛 assert.equal(buildActsQuery(TWIN, { before: '这不是日期' }).$or, undefined); // 脏 twinId:null,让调用方回空 —— 绝不返回一个没有 twinId 的查询(那会跨分身读别人的起居注) assert.equal(buildActsQuery('不是ObjectId', {}), null); assert.equal(buildActsQuery(null, {}), null); }); test('★ 翻页游标:twinId 永远在查询里 —— 少了它就是跨分身泄露', () => { const t = new Date('2026-07-16T10:00:00.000Z'); for (const opts of [{}, { before: t.toISOString(), beforeId: new ObjectId().toString() }, { tool: 'web_search' }, { trigger: 'schedule' }]) { const q = buildActsQuery(TWIN, opts); assert.ok(q.twinId instanceof ObjectId, 'twinId 必须在,且必须是 ObjectId'); assert.equal(q.twinId.toString(), TWIN); } }); // ══════════════════════════════════════════════════════════════════════════ // 1. ★ 跳过分支不记 —— 这一组是全组最重要的 // // tools.js 的循环里,ci >= MAX_CALLS_PER_HOP 的那一支【只回一句「先跳过以控制开销」】, // runTool 根本没被调用。把它记进起居注 = 史官编造朝会。 // 起居注一旦有一条是编的,整本书的性质就变了,而用户永远不知道是哪一条。 // ══════════════════════════════════════════════════════════════════════════ test('★ 跳过分支不记:超过每跳上限的 tool_call 不算「真执行」', () => { // 前 6 个是真执行(MAX_CALLS_PER_HOP = 6) for (let ci = 0; ci < 6; ci++) { assert.equal(isRealCall(ci), true, `第 ${ci} 个调用应算真执行`); } // 第 7 个起只回一句「先跳过」,runTool 没被调用 —— 绝不许记 for (const ci of [6, 7, 12, 99]) { assert.equal(isRealCall(ci), false, `第 ${ci} 个调用没有真执行,记了就是记载分身干了它没干的事`); } }); test('★ 跳过分支不记:埋点必须在 isRealCall 的【真执行】那一支里,不许提到 if 外面', async () => { // 读源码断言结构 —— 这条测试守的是「位置」,不是「行为」,只能这么测。 // 理由:把 recordAct 提到 if/else 外面(看起来更「整洁」)是这个 bug 最可能的复活方式, // 而它不会让任何行为测试变红:跳过的那一条照样入库,没人看得出来。 const src = await (await import('node:fs/promises')).readFile( new URL('../src/services/tools.js', import.meta.url), 'utf8'); const realBranch = src.slice(src.indexOf('if (!isRealCall(ci))')); const elseAt = realBranch.indexOf('} else {'); const pushAt = realBranch.indexOf("conv.push({ role: 'tool'"); const recAt = realBranch.indexOf('recordAct('); assert.ok(elseAt > 0 && pushAt > 0 && recAt > 0, '源码结构变了,这条测试要跟着改'); assert.ok(recAt > elseAt, 'recordAct 必须在 else(真执行)分支【之内】'); assert.ok(recAt < pushAt, 'recordAct 必须在 conv.push 之前 —— 提到 if 外面就会把跳过的那条也记进去'); assert.ok(/recordAct\([\s\S]*?\}\)\.catch\(\(\) => \{\}\)/.test(realBranch), 'recordAct 必须 .catch(()=>{}) —— 不 await 的 promise reject 会 unhandledRejection 打断热路径'); }); // ══════════════════════════════════════════════════════════════════════════ // 2. ★ 截断 —— 共享单机 99G 盘,满盘 = 全站 24 应用连锁瘫 // 起居注【没有 TTL】(那是故意的,见 db.js / acts.js),所以截断是仅有的两道闸之一。 // 闸松了没有第二道防线。 // ══════════════════════════════════════════════════════════════════════════ test('★ 截断:result 硬截断 500 字符', () => { const long = 'x'.repeat(5000); assert.equal(truncResult(long).length, RESULT_MAX); assert.equal(RESULT_MAX, 500, '500 是红线里写死的数,不许放宽'); }); test('截断:result 非字符串也不许漏过闸(对象/数字一律转字符串再截)', () => { assert.equal(truncResult(null), ''); assert.equal(truncResult(undefined), ''); assert.equal(truncResult(12345), '12345'); const big = truncResult({ a: 'y'.repeat(9000) }); assert.ok(big.length <= RESULT_MAX, '对象序列化后同样要截'); }); test('截断:短的原样留全 —— 闸只砍超长的,不许把正常内容也砍了', () => { assert.equal(truncResult('(主人知识库里没有相关内容)'), '(主人知识库里没有相关内容)'); }); test('★ 截断:单个入参值截到 ARG_MAX,键数封顶 —— 别让模型决定我们的文档大小', () => { const args = snapshotArgs({ query: 'q'.repeat(1000) }); assert.equal(args.query.length, ARG_MAX); const many = {}; for (let i = 0; i < 50; i++) many['k' + i] = 'v'; assert.ok(Object.keys(snapshotArgs(many)).length <= ARGS_MAX_KEYS); }); test('snapshotArgs:非法输入不炸(它在热路径上,抛了就白记一条)', () => { assert.deepEqual(snapshotArgs(null), {}); assert.deepEqual(snapshotArgs(undefined), {}); assert.deepEqual(snapshotArgs('nope'), {}); assert.deepEqual(snapshotArgs([1, 2]), {}); // 空值不占位:记一条 {due:''} 没有意义 assert.deepEqual(snapshotArgs({ text: 'a', due: '', p: null }), { text: 'a' }); }); test('★ args 里就是 web_search 的 query —— 全站此前唯一拿不到的东西,必须存得住', () => { const doc = buildActDoc({ twinId: TWIN, tool: 'web_search', args: { query: '杭州明天天气' }, result: '多云' }); assert.equal(doc.args.query, '杭州明天天气', '这一行就是这一刀的全部意义'); }); // ══════════════════════════════════════════════════════════════════════════ // 3. buildActDoc —— 归一化(纯函数,不碰 DB) // ══════════════════════════════════════════════════════════════════════════ test('★ 挂不到 twinId 的一条,宁可不记 —— 它躲得过 deleteTwin 的级联删(合规漏洞)', () => { assert.equal(buildActDoc({ tool: 'web_search' }), null); assert.equal(buildActDoc({ twinId: 'not-an-objectid', tool: 'web_search' }), null); assert.equal(buildActDoc({}), null); }); test('记不出「干了什么」的一条不记(无 tool 名 = 噪音)', () => { assert.equal(buildActDoc({ twinId: TWIN }), null); assert.equal(buildActDoc({ twinId: TWIN, tool: '' }), null); }); test('buildActDoc:twinId/ownerId 必须落成 ObjectId —— deleteTwin 是按 ObjectId 级联删的', () => { const doc = buildActDoc({ twinId: TWIN, ownerId: OWNER, tool: 'web_search' }); assert.ok(doc.twinId instanceof ObjectId, 'twinId 存成字符串 → 级联删匹配不上 → 删了分身起居注还在'); assert.ok(doc.ownerId instanceof ObjectId); assert.equal(doc.twinId.toString(), TWIN); }); test('buildActDoc:ownerId 缺失可为 null,但不许因此丢掉整条', () => { const doc = buildActDoc({ twinId: TWIN, tool: 'get_datetime' }); assert.ok(doc, '没有主人身份的编排类调用也要记 —— 查询按 twinId 走,不靠 ownerId'); assert.equal(doc.ownerId, null); }); test('buildActDoc:trigger 只认白名单,别的一律回落 chat', () => { assert.equal(buildActDoc({ twinId: TWIN, tool: 'x', trigger: 'schedule' }).trigger, 'schedule'); assert.equal(buildActDoc({ twinId: TWIN, tool: 'x', trigger: 'workflow' }).trigger, 'workflow'); assert.equal(buildActDoc({ twinId: TWIN, tool: 'x', trigger: '养成' }).trigger, 'chat'); assert.equal(buildActDoc({ twinId: TWIN, tool: 'x' }).trigger, 'chat'); }); test('buildActDoc:ok 默认 true,只有显式 false 才是 false', () => { assert.equal(buildActDoc({ twinId: TWIN, tool: 'x' }).ok, true); assert.equal(buildActDoc({ twinId: TWIN, tool: 'x', ok: false }).ok, false); }); test('buildActDoc:ms/hop 脏数据不许写进库(NaN/负数一律归 0)', () => { const d = buildActDoc({ twinId: TWIN, tool: 'x', ms: NaN, hop: -3 }); assert.equal(d.ms, 0); assert.equal(d.hop, 0); assert.equal(buildActDoc({ twinId: TWIN, tool: 'x', ms: 12.7, hop: 2 }).ms, 13); }); // ══════════════════════════════════════════════════════════════════════════ // 4. ★ summarizeRows —— 反刷 = 书法不隐 // // 起居注记的是【做事的方式】,不是【做了多少】。 // 一旦这里吐出一个可以被刷高的单一数字,用户优化的就是那个数, // 而这本书照的就不再是他,是他的 build(decisions.js 结构律★三)。 // ══════════════════════════════════════════════════════════════════════════ const rows = () => [ { tool: 'web_search', trigger: 'chat', args: { query: '杭州天气' }, createdAt: new Date('2026-07-10T10:00:00Z') }, { tool: 'web_search', trigger: 'chat', args: { query: '杭州天气' }, createdAt: new Date('2026-07-10T10:01:00Z') }, { tool: 'web_search', trigger: 'schedule', args: { query: '锂电池 最新政策' }, createdAt: new Date('2026-07-12T02:00:00Z') }, { tool: 'add_task', trigger: 'chat', args: { text: '周五交报告' }, createdAt: new Date('2026-07-11T08:00:00Z') }, ]; test('★ 反刷:summarize 绝不许吐出「养成/进度/成就/评分/连续天数」这类字段', () => { const s = summarizeRows(rows()); const keys = JSON.stringify(Object.keys(s)); for (const banned of ['score', 'level', 'progress', 'streak', 'rank', 'achievement', 'exp', 'grade']) { assert.ok(!keys.includes(banned), `summarize 吐了 ${banned} —— 有分数就有人刷分,这本书照的就不再是他`); } // 红线:体力/次数/冷却/每日上限(版号认定)——字段名和文案都不许 for (const banned of ['stamina', 'energy', 'cooldown', 'dailyLimit', 'quota', 'ticket']) { assert.ok(!keys.includes(banned), `summarize 吐了 ${banned} —— 手游机制,会把产品推回「网络游戏」认定`); } }); test('★ 反刷:重复的检索词【不许去重】—— 重复本身就是书法', () => { const s = summarizeRows(rows()); const qs = s.queries.filter((q) => q.query === '杭州天气'); assert.equal(qs.length, 2, '同一个词搜两遍说明当时没搜着,那正是「碰到矛盾怎么处理」的痕迹 —— 去重会把它抹平'); }); test('summarize:记的是【做事的方式】—— 用过哪些工具、搜过哪些词,是分布不是名次', () => { const s = summarizeRows(rows()); assert.equal(s.total, 4); assert.deepEqual(s.tools, [{ tool: 'web_search', n: 3 }, { tool: 'add_task', n: 1 }]); assert.equal(s.queries.length, 3, 'add_task 没有 query 键,不该混进检索词里'); assert.deepEqual(s.queries.map((q) => q.query), ['杭州天气', '杭州天气', '锂电池 最新政策']); }); test('★ summarize:分得开「你让它干的」和「它自己去干的」—— 这是起居注最有味道的一列', () => { const s = summarizeRows(rows()); assert.deepEqual(s.triggers, { chat: 3, schedule: 1 }); }); test('summarize:起讫时间 = 这本起居注记到哪儿了', () => { const s = summarizeRows(rows()); assert.equal(s.from.toISOString(), '2026-07-10T10:00:00.000Z'); assert.equal(s.to.toISOString(), '2026-07-12T02:00:00.000Z'); }); test('summarize:检索词最多留 20 条(前端一页读得完,也别把响应撑爆)', () => { const many = Array.from({ length: 50 }, (_, i) => ({ tool: 'web_search', trigger: 'chat', args: { query: 'q' + i }, createdAt: new Date(), })); assert.equal(summarizeRows(many).queries.length, 20); assert.equal(summarizeRows(many).total, 50, 'total 仍是真实条数,不被 queries 的截断带偏'); }); test('summarizeRows:非法输入不炸,空起居注给一份空的(新分身的第一天,不是错误态)', () => { for (const bad of [null, undefined, 'nope', 123, [null, undefined, {}]]) { const s = summarizeRows(bad); assert.equal(s.total, 0); assert.deepEqual(s.tools, []); assert.deepEqual(s.queries, []); assert.equal(s.from, null); } }); test('summarizeRows:脏 trigger 归入 chat,不许在 summary 里造出白名单外的第五种', () => { const s = summarizeRows([{ tool: 'x', trigger: 'hack', createdAt: new Date() }]); assert.deepEqual(Object.keys(s.triggers), ['chat']); assert.deepEqual(TRIGGERS, ['chat', 'workflow', 'schedule', 'proactive']); }); // ══════════════════════════════════════════════════════════════════════════ // 5. 常量本身 —— 防爆盘的两道闸不许被悄悄放宽 // ══════════════════════════════════════════════════════════════════════════ test('★ 两道闸:截断 500 + 每分身 2000 条 —— 这是「不设 TTL」的前提条件', () => { assert.equal(RESULT_MAX, 500); assert.equal(MAX_ACTS_PER_TWIN, 2000); // 闸松了没有第二道防线:起居注【没有 TTL】(故意的,见 db.js),共享单机 99G 盘满 = 全站瘫。 });