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