Files
digital-me-history/digital-me-backend/test/guardian.test.js
T
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

168 lines
9.2 KiB
JavaScript

// 家长监护的纯逻辑单测(《人工智能拟人化互动服务管理暂行办法》第十四条【第二款】)。
//
// 这里只测【不碰 DB 的那部分】。选这几样的理由跟 minor.test.js 一样:它们是整条链路里
// "算错了也不会报错、只会静默给出错误结果"的地方 ——
// · clampWardLimitMin 少一个 Math.min,家长就能把上限设成 1440 分钟 = 时长限制被合法地关掉;
// · summarizeDays 补零补错,家长看到的"这周用了多久"就是假的,而报表照样漂漂亮亮地渲染;
// · normalizeCode 少认一种写法,家长照着孩子念的抄进去就是"邀请码无效",然后他就不用这个功能了。
// 这三样都没有任何报错会告诉你出了问题。
//
// 跑:node --test test/guardian.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
clampWardLimitMin,
summarizeDays,
normalizeCode,
genCodeString,
} from '../src/services/guardian.js';
import { dayKey, lastDayKeys } from '../src/lib/day.js';
// ── 1. 时长上限:监护人只能调得更严,不能更松 ──
// 【第十四条第二款开头「针对不同年龄段未成年人保护需要」】
// 平台默认值是本站对"使用时长限制"的合规底线。允许家长往上调 = 给每个家长发了一个
// "关掉时长限制"的按钮,那这条法条就靠家长自觉了。这组断言就是这个取向的可执行定义。
test('监护人设的上限只能比平台默认【更严】,不能更松', () => {
// 更严 → 采纳
assert.equal(clampWardLimitMin(30, 60), 30);
assert.equal(clampWardLimitMin(1, 60), 1, '家长想设 1 分钟是他的判断,不设下限');
// 更松 → 收回平台默认(这是本条最重要的一条:漏了它 = 时长限制形同虚设)
assert.equal(clampWardLimitMin(90, 60), 60);
assert.equal(clampWardLimitMin(1440, 60), 60, '「设成一整天」等于关掉限制,必须收回平台默认');
// 相等 → 原样
assert.equal(clampWardLimitMin(60, 60), 60);
});
test('0/空/非法 = 不覆盖(回落平台默认),而不是"没有限制"', () => {
// ⚠ 这一组的反面是致命的:如果非法输入返回 0 或 Infinity 而不是 null,
// getMinorState 里 `?? platformLimitMin` 就兜不住(0 和 Infinity 都不是 nullish),
// 于是 usedMin >= 0 恒真(全拦) 或 usedMin >= Infinity 恒假(全放)。
for (const bad of [0, -5, null, undefined, '', 'abc', NaN, {}, []]) {
assert.equal(clampWardLimitMin(bad, 60), null, `${JSON.stringify(bad)} 必须回落平台默认`);
}
});
test('小数向下取整;平台默认没配好时不拿它当天花板', () => {
assert.equal(clampWardLimitMin(30.9, 60), 30);
// 平台默认是脏值时,不能因此把家长设的值也变成 NaN/0(那会让闸门行为完全不可预测)
assert.equal(clampWardLimitMin(30, 0), 30);
assert.equal(clampWardLimitMin(30, NaN), 30);
assert.equal(clampWardLimitMin(30, undefined), 30);
});
// ── 2. 使用概况:缺的天必须补 0,不能跳过 ──
// 【第十四条第二款「了解未成年人服务使用概况」】
test('逐日概况:没用过的那天补 0,而不是从数组里消失', () => {
const keys = ['2026-07-10', '2026-07-11', '2026-07-12'];
const rows = [{ day: '2026-07-11', usedMs: 120000, asks: 4 }];
const r = summarizeDays(rows, keys);
assert.equal(r.days.length, 3, '缺的天跳过 = 家长看到的折线图把周末悄悄抹掉了');
assert.deepEqual(r.days[0], { day: '2026-07-10', usedMin: 0, asks: 0 });
assert.deepEqual(r.days[1], { day: '2026-07-11', usedMin: 2, asks: 4 });
assert.deepEqual(r.days[2], { day: '2026-07-12', usedMin: 0, asks: 0 });
assert.equal(r.usedMin, 2);
assert.equal(r.asks, 4);
});
test('逐日概况:毫秒→分钟向下取整,合计等于各天之和', () => {
const keys = ['2026-07-10', '2026-07-11'];
const rows = [
{ day: '2026-07-10', usedMs: 59_999, asks: 1 }, // 不足 1 分钟 → 0
{ day: '2026-07-11', usedMs: 3_660_000, asks: 9 }, // 61 分钟
];
const r = summarizeDays(rows, keys);
assert.equal(r.days[0].usedMin, 0);
assert.equal(r.days[1].usedMin, 61);
assert.equal(r.usedMin, 61);
assert.equal(r.asks, 10);
});
test('逐日概况:DB 里的多余天数不进报表(只按请求的 dayKeys 出)', () => {
// usageRows 用 $gte 取区间,理论上不会多带;但报表的形状必须只由 dayKeys 决定,
// 否则某天有人改了查询条件,报表会莫名其妙多出几列。
const keys = ['2026-07-11'];
const rows = [
{ day: '2026-07-01', usedMs: 600000, asks: 50 }, // 区间外的老数据
{ day: '2026-07-11', usedMs: 60000, asks: 1 },
];
const r = summarizeDays(rows, keys);
assert.equal(r.days.length, 1);
assert.equal(r.usedMin, 1, '区间外的 10 分钟不该被算进本周合计');
assert.equal(r.asks, 1);
});
test('逐日概况:空输入不炸(新号还没有任何用量记录)', () => {
assert.deepEqual(summarizeDays([], []), { days: [], usedMin: 0, asks: 0 });
assert.deepEqual(summarizeDays(null, null), { days: [], usedMin: 0, asks: 0 });
const r = summarizeDays(undefined, ['2026-07-11']);
assert.deepEqual(r.days, [{ day: '2026-07-11', usedMin: 0, asks: 0 }]);
});
// ── 3. 邀请码:家长是照着孩子念的抄的 ──
test('邀请码归一化:小写/空格/连字符都要认', () => {
assert.equal(normalizeCode('abcdef'), 'ABCDEF');
assert.equal(normalizeCode(' ABC DEF '), 'ABCDEF');
assert.equal(normalizeCode('AB-CD-EF'), 'ABCDEF', '家长会按看到的分组抄');
assert.equal(normalizeCode('a2c4e6'), 'A2C4E6');
});
test('邀请码归一化:空/非法输入不炸,且不会变成一个"能匹配上"的东西', () => {
// ⚠ 这条是安全性的:如果 normalizeCode(null) 返回 '' 而 linkByCode 又不校验长度,
// 一个空码就会去库里查 {code:''} —— 只要有任何一条脏数据的 code 是空串就绑上了。
// 所以这里保证返回空串,由 linkByCode 的 length !== CODE_LEN 挡掉。
assert.equal(normalizeCode(null), '');
assert.equal(normalizeCode(undefined), '');
assert.equal(normalizeCode(''), '');
assert.equal(normalizeCode('!@#$%^'), '');
assert.equal(normalizeCode('ABCDEFGHIJK'), 'ABCDEF', '超长截断到 6 位');
});
test('邀请码生成:长度固定、字母表不含易混字符(I/L/O/0/1)', () => {
for (let i = 0; i < 200; i++) {
const c = genCodeString();
assert.equal(c.length, 6);
assert.match(c, /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$/, `${c} 含易混字符或非法字符`);
// 归一化必须是生成结果的恒等映射,否则孩子原样念、家长原样抄,也对不上。
assert.equal(normalizeCode(c), c);
}
});
// ── 4. 「今天」只能有一个定义 ──
// minor.js 按它记账、guardian.js 按它查账。两处分叉 = 后端拦人用的数字和家长看到的数字对不上,
// 而且不报任何错。
//
// ⚠ 这里【只测本文件真正依赖的性质】(字典序=时间序、末位是今天)。
// dayKey 的时区语义由 test/day.test.js 权威覆盖,别在两处各测一套。
//
// ⚠ 输入一律用【确切的 UTC 时刻】(new Date('...Z')),不用 new Date(y,m,d,...) ——
// 后者构造的是【本机本地时间】,而 dayKey 现在恒按 Asia/Shanghai 切日:
// 本机若在 UTC-4,本地 7/16 23:59 换算成北京就是 7/17,断言会随开发者所在时区飘。
// (原断言 `dayKey(new Date(2026,6,16,23,59)) === '2026-07-16'` 就是这么挂的 ——
// 它在给「读服务器本地时区」这个 bug 上锁,而那正是我们刚修掉的东西。别写回来。)
const AT = (s) => new Date(s); // 确切 UTC 时刻,与本机时区无关
test('dayKey:格式 YYYY-MM-DD、零填充,且字典序 == 时间序', () => {
// 北京 12:00 —— 离午夜足够远,任何时区的开发者跑都一样
assert.equal(dayKey(AT('2026-07-16T04:00:00Z')), '2026-07-16');
assert.equal(dayKey(AT('2026-01-05T04:00:00Z')), '2026-01-05', '月/日必须补零,否则字典序≠时间序');
// 字典序 == 时间序 —— usageRows 的 { day: { $gte: keys[0] } } 区间查询依赖这一点
assert.ok(dayKey(AT('2026-01-05T04:00:00Z')) < dayKey(AT('2026-01-12T04:00:00Z')));
assert.ok(dayKey(AT('2026-09-09T04:00:00Z')) < dayKey(AT('2026-10-01T04:00:00Z')));
});
test('lastDayKeys:从旧到新、末位是今天、长度为 n', () => {
const keys = lastDayKeys(7, AT('2026-07-16T04:00:00Z'));
assert.equal(keys.length, 7);
assert.equal(keys[6], '2026-07-16', '末位必须是今天 —— wardReport 靠它取 today 那一行');
assert.equal(keys[0], '2026-07-10');
assert.deepEqual([...keys].sort(), keys, '必须已经是升序(区间查询用 keys[0] 当下界)');
});
test('lastDayKeys:跨月/跨年用日历日做减法,不会算出不存在的日期', () => {
// 用 now - i*86400000 毫秒做减法在有夏令时的时区会重复/跳过一天;用日历日则不会。
assert.deepEqual(lastDayKeys(3, new Date(2026, 2, 2)), ['2026-02-28', '2026-03-01', '2026-03-02']);
assert.deepEqual(lastDayKeys(3, new Date(2027, 0, 1)), ['2026-12-30', '2026-12-31', '2027-01-01']);
// 闰年:2028-03-01 往前数三天是 2028-02-28(2028 是闰年,有 2/29)
assert.deepEqual(lastDayKeys(3, new Date(2028, 2, 1)), ['2028-02-28', '2028-02-29', '2028-03-01']);
});