Files
MailUI4Agents/plugins/dsh-mail-bridge/test/bounded.test.mjs
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

153 lines
5.5 KiB
JavaScript
Raw 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.

import assert from 'node:assert/strict';
import test from 'node:test';
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
// ─── 上限常量 ───
test('两个上限的相对大小编码了「丢一条的后果」', () => {
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
// 所以邮件窗口可以给得比会话映射宽。
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
assert.ok(MAX_TRACKED_SESSIONS > 0);
});
// ─── BoundedMap ───
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
const m = new BoundedMap(10);
m.set('a', 1).set('b', 2);
assert.equal(m.size, 2);
assert.equal(m.get('a'), 1);
assert.equal(m.get('b'), 2);
assert.equal(m.has('a'), true);
assert.equal(m.has('zzz'), false);
assert.equal(m.get('zzz'), undefined);
assert.equal(m.evicted, 0);
});
test('BoundedMap 超过上限时丢最老的size 不再增长', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
assert.equal(m.evicted, 1);
});
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3);
m.get('a'); // a 变成最新
m.set('d', 4); // 淘汰最老的 —— 现在是 b不是 a
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
assert.equal(m.has('b'), false);
});
test('BoundedMap 的 peek 不刷新活跃度', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3);
m.peek('a');
m.set('d', 4);
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
});
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
const m = new BoundedMap(2);
m.set('a', 1).set('b', 2).set('a', 9);
assert.equal(m.size, 2);
assert.equal(m.get('a'), 9);
m.set('c', 3);
assert.equal(m.has('b'), false, 'a 被重新 set 过b 才是最老的');
assert.equal(m.has('a'), true);
});
test('BoundedMap 支持 delete / clear / 迭代', () => {
const m = new BoundedMap(5);
m.set('a', 1).set('b', 2);
assert.equal(m.delete('a'), true);
assert.equal(m.delete('a'), false);
assert.deepEqual([...m.entries()], [['b', 2]]);
assert.deepEqual([...m.values()], [2]);
assert.deepEqual([...m], [['b', 2]]);
m.clear();
assert.equal(m.size, 0);
});
// ─── BoundedSet ───
test('BoundedSet 超过上限时丢最老的成员', () => {
const s = new BoundedSet(3);
s.add('m1').add('m2').add('m3').add('m4');
assert.equal(s.size, 3);
assert.equal(s.peek('m1'), false);
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
assert.equal(s.evicted, 1);
});
test('BoundedSet 的 has 刷新活跃度', () => {
const s = new BoundedSet(3);
s.add('a').add('b').add('c');
assert.equal(s.has('a'), true);
s.add('d');
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
assert.equal(s.peek('b'), false);
});
test('BoundedSet 重复 add 不占额外位置', () => {
const s = new BoundedSet(2);
s.add('a').add('a').add('a');
assert.equal(s.size, 1);
});
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
const s = new BoundedSet(5);
s.add('a').add('b');
assert.equal(s.delete('a'), true);
assert.deepEqual([...s], ['b']);
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
assert.deepEqual([...new Set(s)], ['b']);
s.clear();
assert.equal(s.size, 0);
});
// ─── 负向对照:非法上限必须当场报错 ───
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
// 邮件被反复投递,而代码里一行错误都不打。
assert.throws(() => new BoundedMap(0), RangeError);
assert.throws(() => new BoundedSet(0), RangeError);
});
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
for (const bad of [NaN, -1, 'abc', undefined, null]) {
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
}
});
test('小数上限向下取整', () => {
const m = new BoundedMap(2.9);
assert.equal(m.limit, 2);
m.set('a', 1).set('b', 2).set('c', 3);
assert.equal(m.size, 2);
});
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
test('灌一万条之后 size 仍等于上限', () => {
const s = new BoundedSet(100);
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
assert.equal(s.size, 100);
assert.equal(s.evicted, 9900);
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
assert.equal(s.peek('mail-0'), false);
const m = new BoundedMap(100);
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
assert.equal(m.size, 100);
});