289 lines
18 KiB
JavaScript
289 lines
18 KiB
JavaScript
// 起居注的单测 —— 照 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 盘满 = 全站瘫。
|
||
});
|