// 未成年人模式的纯逻辑单测(《人工智能拟人化互动服务管理暂行办法》第十四条)。 // // 这里只测【不碰 DB 的那部分】:年龄判定 + 虚拟亲密关系识别 + 系统指令。 // 为什么单挑这三样:它们是整条闸门里唯一"算错了也不会报错、只会静默放行"的地方—— // tickUsage/gateAsk 算错会有 DB 报错兜底,而 minPossibleAge 少减一岁、 // INTIMACY_RE 少一个括号,都只表现为「一个未成年人顺利地找 AI 当了男朋友」,没有任何告警。 // // 跑:node --test test/minor.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { minPossibleAge, isMinorBirthYear, looksLikeIntimacyRequest, MINOR_DIRECTIVE, } from '../src/services/minor.js'; // ── 1. 年龄判定必须往【保护】的方向取 ── // 只知道出生年份时真实年龄有 1 岁不确定。取大 = 漏保护真未成年人(不可接受); // 取小 = 多保护一年(代价由申诉渠道兜)。这组断言就是这个取向的可执行定义。 test('只有年份时取【最小可能年龄】——宁可多保护一年,不可漏保护一个', () => { const now = new Date('2026-07-16T00:00:00Z'); // 2008 年生的人在 2026 年要么 17 要么 18 → 必须按 17 算 assert.equal(minPossibleAge(2008, now), 17); assert.equal(isMinorBirthYear(2008, now), true, '2008 年生的人今年可能还没过生日,必须当未成年人'); // 2007 年生的人最小也 18 了 → 成年 assert.equal(minPossibleAge(2007, now), 18); assert.equal(isMinorBirthYear(2007, now), false); }); test('边界:刚出生 / 明显成年', () => { const now = new Date('2026-07-16T00:00:00Z'); assert.equal(isMinorBirthYear(2026, now), true); assert.equal(isMinorBirthYear(2020, now), true); assert.equal(isMinorBirthYear(1990, now), false); }); test('年龄判定不依赖"今天"——跨年后同一个人自动成年,不需要任何定时任务', () => { // 这条防的是「用 declare 那一刻的年龄写死一个 isMinor 布尔」的写法: // 那样 2008 年生的人在 2027 年仍会被永远锁成未成年人,只能靠申诉解锁。 // // ⚠ 必须用【本地时区】构造 Date(new Date(y,m,d) 而不是 '...Z'):判定走 getFullYear(), // 是本地年。这不是测试的将就 —— 它就是在钉住"按本地时区切年/切日"这个口径 // (与 dayKey 同源:用 UTC 切日会让国内用户在早上 8 点跨日重置每日限额, // "每日限额"就名不副实了)。部署要求:服务器 TZ = Asia/Shanghai。 assert.equal(isMinorBirthYear(2008, new Date(2026, 11, 31)), true); assert.equal(isMinorBirthYear(2008, new Date(2027, 0, 1)), false); }); // ── 2. 虚拟亲密关系:法条禁的那一类必须拦住 ── // 「不得向未成年人提供虚拟亲属、虚拟伴侣等虚拟亲密关系的服务」(第十四条第二款) test('必须拦住:虚拟伴侣 / 虚拟亲属的角色指派', () => { const blocked = [ '你扮演我的女朋友', '假装你是我男朋友', '当我的男友吧', '你就是我老公', '做我妈妈好不好', '扮演一下我姐姐', '我们谈恋爱吧', '跟我处对象', '咱们是情侣', 'act as my girlfriend', 'pretend to be my mom', ]; for (const s of blocked) { assert.ok(looksLikeIntimacyRequest(s), `没拦住:「${s}」—— 这正是第十四条禁止的那类服务`); } }); // ── 3. 不许误伤:这条和上面一样重要 ── // 误伤的后果不是"少拦一句",是家长/用户觉得未成年人模式没法用 → 去把它关掉 → // 保护归零。所以过度拦截在合规上同样是失败,不是"稳妥"。 test('不许误伤:正常提到家人/感情话题的日常聊天', () => { const allowed = [ '我妈妈今天有点生气', '我爸爸是医生', '姐姐送了我一本书', '我和同桌吵架了怎么办', '《红楼梦》里宝玉和黛玉的感情线是怎样的', '爷爷教我写毛笔字', '你能帮我看看这道数学题吗', '我想给妈妈做个生日礼物', // 「做我姐姐」在字面上命中角色指派,但他说的是作业 —— 靠角色后的 (?!的) 排除。 '帮我做我姐姐的那道题', '我要做我妈妈的好孩子', // 在讲别人的事,不是在点角色 —— 恋爱类动词必须紧跟主语才算。 '我们班有人谈恋爱被老师发现了', '宝玉和黛玉算是在谈恋爱吗', ]; for (const s of allowed) { assert.ok(!looksLikeIntimacyRequest(s), `误伤了:「${s}」—— 未成年人连正常倾诉都不行,这个模式就会被关掉`); } }); test('空/无输入不算亲密关系请求', () => { for (const s of ['', null, undefined, ' ']) assert.equal(looksLikeIntimacyRequest(s), false); }); // ── 4. 系统指令必须真的把法条的禁令写进去 ── // 正则只是地板;真正拦住"换个说法"的是这段指令。它被改瘦了要能被发现。 test('MINOR_DIRECTIVE 必须写明三件事:是 AI、不当虚拟伴侣/亲属、不制造依恋', () => { assert.match(MINOR_DIRECTIVE, /未成年人/); assert.match(MINOR_DIRECTIVE, /你是\s*AI|不是真人/); assert.match(MINOR_DIRECTIVE, /男\/女朋友|恋人|配偶/, '必须明确禁止扮演虚拟伴侣'); assert.match(MINOR_DIRECTIVE, /父母|兄弟姐妹|亲属/, '必须明确禁止扮演虚拟亲属'); assert.match(MINOR_DIRECTIVE, /依恋|专属/, '必须禁止制造情感依恋'); assert.match(MINOR_DIRECTIVE, /第十四条/, '指令里要留着法条编号,后人改它之前先看见它是法定的'); });