// 共走(两人/多人一起走过同一道大事)的【角色对应关系】单测 —— 纯函数、不需 DB。 // 证四件事:① 性别识别对真人可靠(后/妃/公主/则天→女,官员→男,显式 sex 可覆盖); // ② 女性首发只筛出【真实女性权力角色】、排除男性、去重已认领;③ 二选一是"你 vs 历史"、不编下场; // ④ 合传把双证(cite)原样带出,不丢不编。 // 跑:node --test test/gongzou.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { sexOf, pickRoles, guestChoice, weave, femalePath, listNvziDynasties, nvziRoles, nvziZhaoming, nvMing, femaleEnding } from '../src/services/gongzou.js'; // 取自 唐·永徽六年「废王立武」真实 cast(people[],各带 zhishi/cite 双证)。 const CAST = [ { ren: '武则天', guan: '太后 → 聖神皇帝', verb: '改國號周', role: 'throne', zhishi: '本太宗才人…载初元年改唐为周、称帝。', cite: '《新唐書》卷四:「壬午,改國號周。」' }, { ren: '王皇后', guan: '皇后(王氏)', verb: '被廢', role: 'fell', zhishi: '高宗原配…永徽六年被废。', cite: '《新唐書》卷四:「高宗廢皇后王氏」' }, { ren: '褚遂良', guan: '尚書左僕射', verb: '力諫·被貶', role: 'other', zhishi: '太宗顾命…力谏废王立武被贬。', cite: '《新唐書·宰相表》卷61' }, { ren: '长孙无忌', guan: '太尉', verb: '力沮·後賜死', role: 'other', zhishi: '太宗元舅、顾命首辅…自缢死。', cite: '《新唐書·宰相表》卷61' }, ]; test('★ 性别识别:后/妃/公主/则天→女,官员→男,显式 sex 覆盖', () => { assert.equal(sexOf(CAST[0]), 'f', '武则天(则天/太后)应为女'); assert.equal(sexOf(CAST[1]), 'f', '王皇后(皇后)应为女'); assert.equal(sexOf(CAST[2]), 'm', '褚遂良(仆射)应为男'); assert.equal(sexOf(CAST[3]), 'm', '长孙无忌(太尉)应为男'); assert.equal(sexOf({ ren: '某女官', guan: '尚宮' }), 'f', '女官/尚宫应为女'); assert.equal(sexOf({ ren: '某人', guan: '太尉', sex: 'f' }), 'f', '显式 sex 必须覆盖头衔启发'); }); test('★★★ 防误判:guan 里【引用】别人的女称,绝不把男性当成女性角色', () => { assert.equal(sexOf({ ren: '于琮', guan: '宰相 · 駙馬(尚廣德公主)' }), 'm', '尚公主的驸马是男'); assert.equal(sexOf({ ren: '仁宗(趙禎)', guan: '皇帝(幼沖·太后垂簾)' }), 'm', '太后垂帘下的皇帝是男'); assert.equal(sexOf({ ren: '后勝', guan: '齊相' }), 'm', '姓后的齐相是男(裸「后」不判女)'); assert.equal(sexOf({ ren: '陈后主', guan: '皇帝' }), 'm', '「后主」是男帝'); // 真女性仍命中:ren 带女称 / guan 开头是本人官职 assert.equal(sexOf({ ren: '武氏', guan: '宸妃 → 皇后' }), 'f', 'guan 开头本人官职→女'); assert.equal(sexOf({ ren: '賈后(賈南風)', guan: '皇后' }), 'f', 'guan 皇后→女'); assert.equal(sexOf({ ren: '太平公主', guan: '鎮國太平公主' }), 'f'); }); test('★★ 女性首发:只筛出真实女性权力角色、排除男性、去重已认领', () => { const f = pickRoles(CAST, 'f', []); assert.deepEqual(f.map((r) => r.ren), ['武则天', '王皇后'], '女性只应看到武则天/王皇后'); assert.ok(f.every((r) => r.cite), '每个对位角色必须带 cite 双证'); const f2 = pickRoles(CAST, 'f', ['武则天']); // 武则天已被认领 assert.deepEqual(f2.map((r) => r.ren), ['王皇后'], '已认领的角色不再出现'); const m = pickRoles(CAST, 'm', []); assert.deepEqual(m.map((r) => r.ren), ['褚遂良', '长孙无忌'], '兄弟一起=筛男性角色(引擎中立,零改动)'); const any = pickRoles(CAST, 'any', []); assert.equal(any.length, 4, 'any=全体在场'); }); test('★ 二选一是"你 vs 历史"、两个选项、不预告下场', () => { const ch = guestChoice({ ren: '武则天', guan: '太后', verb: '改國號周', role: 'throne', sex: 'f' }); assert.match(ch.prompt, /武则天/); assert.match(ch.prompt, /改國號周/, '提示里要带她史上真做的事'); assert.equal(ch.options.length, 2); assert.deepEqual(ch.options.map((o) => o.id), ['go', 'stop']); assert.match(ch.prompt, /只给男人留位置/, '女性专门设计:提示要点出"只给男人留位置的朝堂"'); }); test('★★ 女子那条真实的路:从官衔判出 女皇/临朝/后位/宗室/后宫/女官', () => { assert.equal(femalePath({ ren: '武则天', guan: '太后 → 聖神皇帝' }).key, 'nvhuang', '称帝→女皇(顶)'); assert.equal(femalePath({ guan: '太皇太后 · 臨朝稱制' }).key, 'linchao', '临朝称制'); assert.equal(femalePath({ guan: '皇后(王氏)' }).key, 'houwei', '后位'); assert.equal(femalePath({ ren: '太平公主', guan: '鎮國太平公主' }).key, 'gongzhu', '天家之女'); assert.equal(femalePath({ guan: '宸妃 → 皇后' }).key, 'houwei', '已升皇后→后位(皇后先于妃)'); assert.equal(femalePath({ guan: '尚宮' }).key, 'nvguan', '女官'); }); test('★★ 单独玩(A):nvziRoles 出真实女性角色,带路径/两难/双证/所属大事', () => { const { roles } = nvziRoles('tang'); assert.ok(roles.length >= 3, '唐应有多位女性(武则天/太平公主/王皇后/武氏…)'); assert.ok(roles.some((r) => r.ren === '武则天' && r.path.key === 'nvhuang'), '武则天=女皇'); for (const r of roles) { assert.equal(r.sex, 'f', '单独玩只出女性'); assert.ok(r.choice && r.choice.options.length === 2, '每个女子都带两难'); assert.ok(r.event && r.event.title, '带所属大事'); } }); test('★ 单独玩(A):listNvziDynasties 只列有女性的朝、带数量与样本', () => { const ds = listNvziDynasties(); assert.ok(ds.length >= 8, '至少 8 朝有女性角色'); assert.ok(ds.every((d) => d.count > 0 && d.name), '每朝都有数量与朝名'); assert.ok(ds.some((d) => d.code === 'tang'), '含唐'); }); test('★★ 女子照命(B):各朝天花板取最高女子路径、用真到顶的女人做证、最高在前', () => { const z = nvziZhaoming(); assert.ok(z.length >= 8, '至少 8 朝'); const tang = z.find((x) => x.code === 'tang'); assert.ok(tang && tang.top.path.key === 'nvhuang' && tang.top.ren === '武则天', '唐顶=女皇·武则天'); assert.equal(z[0].code, 'tang', '女皇那朝(唐)天花板最高、排最前'); assert.ok(z.every((x) => x.top && x.top.ren && x.top.path), '每朝都有做证的女人+路径'); }); test('★★ 女子照命·个人推演(B):门第/才→各朝女子命,落差随门第变、命被门第压制、明标推演', () => { const rich = nvMing({ jiashi: 3, cai: 1 }); assert.equal(rich.tuiyan, true, '必须明标推演'); assert.ok(rich.rows.length >= 8); const tang = rich.rows.find((r) => r.code === 'tang'); assert.ok(tang && tang.linchaoReachable, '名门女子·唐(顶=女皇) 应望临朝'); assert.ok(rich.spread, '门第高各朝有落差'); assert.ok(rich.rows.every((r) => r.ceiling && r.ceiling.ren), '每朝天花板都有真人做证'); const poor = nvMing({ jiashi: 0, cai: 0 }); assert.ok(poor.rows.every((r) => !r.linchaoReachable), '寒微无才:哪朝都望不到临朝'); assert.ok(poor.rows.every((r) => r.reach.tier <= 3), '寒微无才:各朝都低 —— 命被门第压制'); }); test('★★ 女主角结局:传世替无名女子立传,到顶者不谎称被抹', () => { const e1 = femaleEnding({ ren: '武则天', guan: '太后 → 聖神皇帝' }); // 女皇 assert.match(e1.chuanshiTop, /本纪|后妃传|留了位置/, '到顶的女子:史书留了名(不谎称她被抹去)'); assert.match(e1.chuanshiClose, /替她们立/, '也替无数无名女子立传'); const e2 = femaleEnding({ ren: '某宫人', guan: '才人' }); // 后宫(未到顶) assert.match(e2.chuanshiTop, /某氏|某女|没给女子立传/, '未到顶:史书多半没立传'); assert.ok(e1.huixiang && e2.huixiang, '回响两者都有'); }); test('★★ 合传:双证(cite)原样带出、织成"你们俩一起走过"一句', () => { const session = { dynName: '唐', hostName: '阿康', ev: { year: 655, title: '废王立武', gloss: null }, hostSeat: { weiLabel: '门下·谏官', roleKey: 'juzhong', roleLabel: '身在局中' }, hostPick: { text: '附立', end: { tone: 'sheng', word: '受赏' } }, }; const seat = { ...CAST[0], sex: 'f' }; const join = { name: '小敏', seatRen: '武则天', pick: { id: 'go', text: '也走到那一步' } }; const hz = weave(session, join, seat); assert.equal(hz.guest.ren, '武则天'); assert.equal(hz.guest.truth.cite, CAST[0].cite, '合传必须把真人 cite 双证原样带出'); assert.match(hz.line, /唐·废王立武/); assert.match(hz.line, /阿康/); assert.match(hz.line, /小敏是武则天/); });