Files
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

289 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 起居注的单测 —— 照 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 盘满 = 全站瘫。
});