// 劳的单测 —— 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, '★ 活跃度是賢愚同滯的反面,拦住'); });