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

196 lines
14 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.
// 劳的单测 —— 2026-07-17 第三刀立的。
//
// 这个文件守四件事,重要性从高到低:
// ①【劳刷不动】—— 老板的原话:「想清楚劳到底该读起居注的哪一个量 —— 而且它必须是刷不动的」。
// 这一条不是靠注释保证的:喂给它一万条起居注,读数必须【一个字都不动】。
// ②【劳不是签到】—— 断中间不归零、不奖励频率。这个区别是版号级的。
// ③【劳进排序键是一个可证明的空操作】—— ★ 所以它不进。这一条用断言证明,不是用注释声称。
// ④【本轮的原文一条劳的闸都授权不了】—— 这是结论,不是没干活。谁想加闸,先让这条测试红。
//
// 跑:node --test test/lao.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { laoDays, laoFromSummary, LAO_SOURCES, LEX_NOOP, XIANYU_TONGZHI, usableForGate, gateableSources } from '../src/lao.js';
import { summarizeRows } from '../src/services/acts.js';
import { DYNASTIES, computeRank, profileFromSubs, getDynasty } from '../src/dynasties.js';
import { assertNoGameMechanics } from '../src/decisions.js';
const DAY = 24 * 60 * 60 * 1000;
const at = (daysAgo) => new Date(Date.now() - daysAgo * DAY);
// 造一条起居注:t 天前的一次工具调用。
const act = (daysAgo, tool = 'web_search', query = '同一个词') => ({
tool, trigger: 'chat', args: { query }, createdAt: at(daysAgo),
});
// ══════════════════════════════════════════════════════════════════════════
// ① ★★★ 刷不动 —— 这一维存在的前提
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 劳刷不动:一万条起居注,读数一个字都不动', () => {
// 甲:两条,跨度 100 天。
const jia = summarizeRows([act(100), act(0)]);
// 乙:一万零两条,跨度【同样】100 天 —— 他把分身往死里刷了一万次。
const yi = summarizeRows([act(100), ...Array.from({ length: 10000 }, (_, i) => act(50 + (i % 40) / 40)), act(0)]);
assert.equal(yi.total, 10002, '(前提:乙确实刷了一万条)');
assert.equal(jia.total, 2);
assert.equal(
laoFromSummary(yi), laoFromSummary(jia),
'★★★ 刷一万条,劳【一天都不多】。若这条挂了,劳就成了「条数越多分越高」——' +
'起居注的反刷设计(acts.js:「重复检索词故意不去重」「风格刷不出来,因为刷本身也是一种风格」)当场作废'
);
assert.equal(laoFromSummary(jia), 100);
});
test('★★★ 劳只碰 from/to —— 碰了条数,反刷设计就废了', () => {
// 这一条是【结构性】的:直接把 total 和 tools 拿掉,读数必须完全一样。
// ⇒ 证明 laoFromSummary 在数据上就够不着条数,而不是「我保证我没读它」。
const full = summarizeRows([act(365), act(200), act(200), act(0)]);
const stripped = { from: full.from, to: full.to };
assert.equal(laoFromSummary(stripped), laoFromSummary(full), '★ 劳只需要两个时间戳,条数它够不着');
assert.equal(laoFromSummary(full), 365);
});
// ══════════════════════════════════════════════════════════════════════════
// ② ★★★ 不是签到 —— 这个区别是版号级的
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 劳不是连续天数:断了两年,劳照样是两年(「一个人熬了二十年就是二十年」)', () => {
// 甲:天天用,跨度 730 天。
const tiantian = summarizeRows(Array.from({ length: 731 }, (_, i) => act(730 - i)));
// 乙:两年前用过一次,今天又用了一次 —— 中间【整整两年没碰】。
const duandiao = summarizeRows([act(730), act(0)]);
assert.equal(
laoFromSummary(duandiao), laoFromSummary(tiantian),
'★★★ 断了两年的人,劳与天天用的人【一样】。' +
'这不是 bug —— 这就是「劳」这个字的意思:「一个人熬了二十年就是二十年,' +
'他这二十年里勤快还是偷懒,劳这个字不管」。' +
'★ 若这条挂了,说明有人给劳加了「连续」或「频率」—— 那一刻它就成了签到,成了留客钩子。'
);
assert.equal(laoFromSummary(duandiao), 730);
// ★★ 而原文自己把这个代价写出来了:賢愚同滯。
assert.match(XIANYU_TONGZHI.quote, /賢愚同滯/, '尺子的失效模式,原文替我们写好了 —— 不许「修」它');
});
test('★★★ 劳推不回去:唯一能让它变大的办法是【等】', () => {
const base = summarizeRows([act(30), act(0)]);
assert.equal(laoFromSummary(base), 30);
// 今天再刷十次 —— to 已经是今天了,跨度不动。
const spam = summarizeRows([act(30), ...Array.from({ length: 10 }, () => act(0))]);
assert.equal(laoFromSummary(spam), 30, '★ to 已经在今天,再刷也推不动');
// ★★ 而 from 【推不回去】:你没有任何办法让「第一笔」发生得更早。
// 这一条就是劳刷不动的全部数学:跨度 = to − from,to 有上限(今天),from 不可变。
const older = summarizeRows([act(31), act(0)]);
assert.equal(laoFromSummary(older), 31, '要多一天,只有一个办法:早一天开始,或者等一天');
});
test('★ 劳向下取整、不为负、读不出就是 0', () => {
assert.equal(laoDays(at(29.9), at(0)), 29, '熬了 29 天半就是 29 天 —— 没过完的那半天不算你的');
assert.equal(laoDays(at(0), at(10)), 0, '倒过来 → 0,不许出负数');
assert.equal(laoDays('不是日期', new Date()), 0);
assert.equal(laoDays(null, null), 0);
assert.equal(laoFromSummary(null), 0);
assert.equal(laoFromSummary({}), 0, '空起居注 → 劳 0(新分身没有熬过,这是实话)');
assert.equal(laoFromSummary(summarizeRows([])), 0);
});
test('★★ 劳的单位是【天】,不许被压进 0–100', () => {
// ★ 这不是洁癖:一旦劳也是 0–100,就会有人把它跟六轴加起来平均,
// 而加权求和正是上一刀删掉的东西。让【单位本身】挡住那件事。
const tenYears = summarizeRows([act(3650), act(0)]);
assert.equal(laoFromSummary(tenYears), 3650, '★ 熬了十年就是 3650 天 —— 不许 clamp 到 100,不许折成百分比');
// 而引擎里也不许压:val('lao') 必须原样透传。
const r = computeRank({ ...profileFromSubs(['rigorous'], { wang: 0 }), lao: 3650 }, 'han');
assert.ok(r, '(引擎吃得下一个 3650 的劳)');
});
// ══════════════════════════════════════════════════════════════════════════
// ③ ★★★ 劳进排序键 = 可证明的空操作 ⇒ 所以它不进
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 劳【不在】任何一条通道的排序键里 —— 因为那是一个可证明的空操作', () => {
// 原文的字典序比【两个候选人】(「德均以才,才均以勞。得者為留,不得者為放」);
// 而 computeRank 的排序键比【同一个人的两条通道】。同一个人 ⇒ val('lao') 在两条路上是同一个数
// ⇒ 永远判平 ⇒ 永远不生效。
// ★ 空操作比缺席更坏:一条永远不生效的规则,在数据里长得和生效的一模一样
// (penaltyIfNoWang 就是本表已有的那个标本)。所以:不许有人「顺手」把它加进去。
for (const d of DYNASTIES) {
for (const c of d.channels) {
assert.ok(
!(c.xuan.key || []).includes('lao'),
`${d.name}/${c.name} 的排序键里出现了 lao —— 它在这个引擎里【永远不会生效】(见 lao.js LEX_NOOP)。` +
'要让劳起作用,只有 gate 一条路'
);
}
}
assert.match(LEX_NOOP.claim, /空操作/);
});
test('★★★ 证明它确实是空操作:劳从 0 涨到十年,择路与位阶【一格都不动】', () => {
// 这一条不是「守着它没被加进去」,是【证明加进去也没用】—— 两回事。
// ★ 若哪天有人给劳找到了一条 usable 的原文并给某条路加了闸,这条测试会红 —— 那是【对的】:
// 到那天,劳就真的在起作用了,这条断言该跟着改。它红的那一刻,就是劳落地的那一刻。
const who = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'], { wang: 50 });
for (const d of DYNASTIES) {
const cold = computeRank({ ...who, lao: 0, chushen: 'qidan' }, d.id);
const veteran = computeRank({ ...who, lao: 3650, chushen: 'qidan' }, d.id);
assert.equal(
cold.channel?.id ?? null, veteran.channel?.id ?? null,
`${d.name}:熬了十年却换了一条路 —— 那说明劳在某处生效了,而本表说它没有任何一条 usable 的料`
);
assert.equal(cold.rank.tier, veteran.rank.tier, `${d.name}:熬了十年却涨了位阶 —— 同上`);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ④ ★★ 本轮的原文,一条劳的闸都授权不了 —— 这是结论,不是没干活
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 现在【一条】劳的闸都没有授权 —— 谁想加,先让这条红', () => {
assert.deepEqual(
gateableSources(), [],
'★ 有料授权劳的闸了?那 lao.js 的 LAO_SOURCES 里该有一条 usable:true —— ' +
'而加它之前,先回答那条料是不是「传序/志序」(usableAsInstitution=false)、' +
'是不是「铨选/考课」(引擎里没有那一段)。见 lao.js NOT_DONE'
);
// 四条候选,逐条钉死它为什么用不了 —— 免得日后有人「顺手」把某一条捡起来。
assert.equal(usableForGate('nc_jiulao'), false, '南齐「久勞」:倖臣传【序】,usableAsInstitution=false');
assert.equal(usableForGate('tang_laoxiao'), false, '唐「才均以勞」:【铨选/校功】,引擎里没有那一段');
assert.equal(usableForGate('song_nianlao'), false, '宋「以年勞取人」:志序 + 转述,双重折扣');
assert.equal(usableForGate('song_wuxue_sannian'), false, '宋武學「三年無過」:位阶算术是【跳】,本引擎只会【加】');
assert.equal(usableForGate('__nope__'), false, '★ 失效方向朝关:认不出的一律 false');
assert.equal(LAO_SOURCES.length, 4, '四条候选都在表上(少一条 = 有人把它删了,而不是解决了它)');
for (const s of LAO_SOURCES) {
assert.ok(s.whyNotUsable && s.whyNotUsable.length > 60, `${s.id} 没说清楚为什么用不了`);
assert.ok(s.quote, `${s.id} 缺原文`);
}
});
test('★★ 而 gate 的【通路】是通的:劳进得了 gate,只是没有料授权它', () => {
// 老板的要求是「两者都要能进 dynasties.js 的排序键与 gate」。
// ★ 「能进」= 管道通。这一条证明管道是通的:拿一条【假想的】劳闸跑一遍引擎。
// ⚠ 这是测试里的假想数据,【不是】往本表里加了一条闸 —— 本表一条都没有(上一条测试钉着)。
const fake = { gate: { lao: 1095 }, xuan: { seat: 1, kemu: [], key: ['lao'], keySource: null } };
const need = fake.gate.lao;
const val = (k, profile) => (k === 'lao' ? profile.lao : 0);
assert.equal(val('lao', { lao: 0 }) < need, true, '熬不够 ⇒ 闸关着');
assert.equal(val('lao', { lao: 2000 }) < need, false, '熬够了 ⇒ 闸开了');
// ★★ 而引擎里 val('lao') 确实认得劳,且【不被 clamp 到 100】——
// 这一条是真的跑引擎,不是假想:一个 lao=3650 的 profile 进得去、出得来、不报错。
const r = computeRank({ ...profileFromSubs(['rigorous'], { wang: 0 }), lao: 3650 }, 'ming');
assert.ok(r.channel, '引擎吃得下劳');
});
test('★★ 红线:劳的结构与文案里不许有签到/连续/活跃度', () => {
assert.deepEqual(assertNoGameMechanics(LAO_SOURCES), [], '劳的料表里有手游机制');
assert.deepEqual(assertNoGameMechanics(LEX_NOOP), []);
assert.deepEqual(assertNoGameMechanics(XIANYU_TONGZHI), []);
// ★ 而红线名单自己也得守:这一批是劳带来的新诱惑,必须在名单上。
// (下一个来接劳的人最容易加的就是 streak —— 因为它「看起来只是把劳做得更细」。)
assert.deepEqual(
assertNoGameMechanics({ streak: 12 }).length, 1, '★ streak 必须被红线拦住'
);
assert.equal(assertNoGameMechanics({ consecutiveDays: 12 }).length, 1, '★ 连续天数必须被拦住');
assert.equal(assertNoGameMechanics({ note: '已连续登录 12 天' }).length, 1, '★ 文案一侧也要拦');
assert.equal(assertNoGameMechanics({ note: '本月活跃度 87' }).length, 1, '★ 活跃度是賢愚同滯的反面,拦住');
});