// 多维钩子(第二擅长 + 性格 → 副天赋并集 → computeRank)的单测。 // // 守四件事: // ① chip → subs 只落在【现有 19 项表达型】副天赋里(排除治学 hobby,防后人映到不存在的项)。 // ② mapChipsToSubs 纯函数:并集、去重、未知 id 优雅忽略、query 两种形态("a,b" / 数组)都摊平。 // ③ 回落:不传 talents/traits ⇒ 合成 subs 与纯职业【逐字相同】,computeRank 过全朝【逐格相同】。 // —— 这一条就是「不破 /zhiye/:id、611 基线不动」的机器证明。 // ④ 多维【真改落点】:产品经理 + 沉得住(→沉毅) 让【宋】从「白身」变成走通的科举正途; // 任务示例 产品经理 + 会写 + 直 至少改变一朝 —— 位阶是 computeRank 真算的,比纯职业更细。 // // 跑:node --test test/zhiye-compose.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { tuiyanProfile, mapChipsToSubs, listChips, CHIPS, VALID_SUBS } from '../src/zhiye.js'; import { computeAxes, computeRank, listDynasties } from '../src/dynasties.js'; import { SUB_REGISTRY } from '../src/talents.js'; // 与 routes/tianxia.js /zhiye/:id 里【逐字同源】的合成逻辑:职业 subs ∪ chip subs。 function composeSubs(zhiyeId, talents, traits) { const base = tuiyanProfile(zhiyeId); const { subs: extra, chips } = mapChipsToSubs(talents, traits); const subs = base.subs.slice(); for (const s of extra) if (!subs.includes(s)) subs.push(s); return { subs, chips }; } // 一个 subs 过全朝的位阶指纹(真跑 computeRank,不用快照)。 function fingerprint(subs) { const profile = { axes: computeAxes(subs) }; const out = {}; for (const d of listDynasties()) { try { const r = computeRank(profile, d.id); out[d.id] = r.rank ? `${r.rank.title}|t${r.rank.tier}|${r.channel ? r.channel.id : '-'}` : null; } catch { out[d.id] = 'ERR'; } } return out; } // ── ① 白名单:每个 chip 的每个 sub 都在现有表达型副天赋里 ── test('★ chip → subs 只落在现有 19 项表达型副天赋里(不发明新 sub)', () => { const valid = new Set(VALID_SUBS); const expressive = SUB_REGISTRY.filter((item) => item.subtype !== 'hobby').map((item) => item.id).sort(); assert.equal(valid.size, 19, '白名单必须正好 19 项表达型副天赋'); assert.deepEqual([...valid].sort(), expressive, '白名单必须与 talents.js 的非 hobby 项逐一一致'); for (const c of CHIPS) { assert.ok(c.kind === 'tuiyan', `${c.id} 必须标 kind:'tuiyan'(是推演不是史料)`); assert.ok(typeof c.basis === 'string' && c.basis.length > 4, `${c.id} 必须有可反驳的 basis`); assert.ok(c.subs.length > 0, `${c.id} 至少映一项`); for (const s of c.subs) assert.ok(valid.has(s), `${c.id} 映到了不存在的副天赋: ${s}`); } }); // ── ② mapChipsToSubs 纯函数行为 ── test('★ mapChipsToSubs:并集 + 去重 + 未知 id 忽略', () => { // 会写(story,vivid) + 直(blunt) const a = mapChipsToSubs('huixie', 'zhi'); assert.deepEqual(a.subs, ['story', 'vivid', 'blunt']); assert.deepEqual(a.chips.map((c) => c.id), ['huixie', 'zhi']); // 直(blunt) + 刚(blunt) ⇒ blunt 去重成一项;两个 chip 都回显 const b = mapChipsToSubs('', 'zhi,gang'); assert.deepEqual(b.subs, ['blunt']); assert.deepEqual(b.chips.map((c) => c.id), ['zhi', 'gang']); // 未知 id 优雅忽略,不抛错 const c = mapChipsToSubs('nope,huisuan', 'bogus'); assert.deepEqual(c.subs, ['datadriven', 'rigorous']); assert.deepEqual(c.chips.map((c2) => c2.id), ['huisuan']); }); test('★ mapChipsToSubs:query 两种形态("a,b" 字符串 / 数组)都摊平', () => { const asString = mapChipsToSubs('huixie,chendezhu', 'zhi'); const asArray = mapChipsToSubs(['huixie', 'chendezhu'], ['zhi']); assert.deepEqual(asString.subs, asArray.subs); // 重复 chip id 也去重(数组里传两遍) const dup = mapChipsToSubs(['huixie', 'huixie'], undefined); assert.deepEqual(dup.chips.map((c) => c.id), ['huixie']); }); // ── ③ 回落:不传 chip ⇒ 与纯职业逐字相同(不破 /zhiye/:id) ── test('★★ 回落:不传 talents/traits ⇒ subs 与位阶都与纯职业【逐字相同】', () => { for (const zid of ['chanpin', 'chengxuyuan', 'yisheng', 'jizhe']) { const base = tuiyanProfile(zid).subs; for (const args of [[undefined, undefined], ['', ''], [[], []], ['', undefined]]) { const { subs, chips } = composeSubs(zid, args[0], args[1]); assert.deepEqual(subs, base, `${zid} 回落 subs 必须逐字等于纯职业`); assert.equal(chips.length, 0, `${zid} 回落不应有 chip`); } // 位阶指纹也必须逐格相同 —— 这才是「611 基线不动」的证明 assert.deepEqual(fingerprint(composeSubs(zid).subs), fingerprint(base)); } }); // ── ④ 多维真改落点 ── test('★★ 产品经理 + 沉得住(→沉毅) 让【宋】从白身变成走通的科举正途', () => { const pure = fingerprint(tuiyanProfile('chanpin').subs); const { subs, chips } = composeSubs('chanpin', 'chendezhu', ''); const multi = fingerprint(subs); assert.deepEqual(chips.map((c) => c.name), ['沉得住']); // 纯产品经理在宋是白身;加一笔「沉得住」后宋这一格【变了】且不再是白身 assert.ok(/白身/.test(pure.song), '前提:纯产品经理在宋应是白身'); assert.notEqual(multi.song, pure.song, '加沉得住后宋这一格必须变'); assert.ok(!/白身/.test(multi.song), '加沉得住后宋不再是白身(走通了科举正途)'); }); test('★★ 任务示例:产品经理 + 会写 + 直 —— 位阶真算、比纯职业更细(至少改一朝)', () => { const base = tuiyanProfile('chanpin').subs; const pure = fingerprint(base); const { subs, chips } = composeSubs('chanpin', 'huixie', 'zhi'); const multi = fingerprint(subs); assert.deepEqual(chips.map((c) => c.name), ['会写', '直']); // 合成六轴:会写→wen、直→wu 都长起来了(真算,不是编) const ax = computeAxes(subs); assert.ok(ax.wen > computeAxes(base).wen, '会写应抬高文采轴'); assert.ok(ax.wu > computeAxes(base).wu, '直应抬高果决轴'); // 至少一朝的落点与纯职业不同 const changed = listDynasties().filter((d) => multi[d.id] !== pure[d.id]).map((d) => d.id); assert.ok(changed.length >= 1, `多维应至少改变一朝,实测改变: ${changed.join(',')}`); }); // ── listChips 分组(供前端渲染) ── test('★ listChips 按 talent/trait 分组,每笔带 basis/kind', () => { const { talents, traits, caveat } = listChips(); assert.ok(talents.length >= 6 && traits.length >= 6); assert.ok(typeof caveat === 'string' && caveat.length > 10); for (const c of [...talents, ...traits]) { assert.ok(c.id && c.name && Array.isArray(c.subs) && c.subs.length > 0); assert.equal(c.kind, 'tuiyan'); assert.ok(typeof c.basis === 'string' && c.basis.length > 4); } });