import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { buildCandidateContrastPacket, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { EXISTENCE_STYLE_OPTIONS, LABEL_MAX, LABEL_MIN, QUALITY_STYLE_OPTIONS, completeStyleOptions, isRenderableStyleOptions, } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { D9_TYPE_TABLE, D10_TYPE_TABLE, d9StyleLabel, d10StyleLabel, } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts"; const SIGNS = [ "白羊座", "金牛座", "双子座", "巨蟹座", "狮子座", "处女座", "天秤座", "天蝎座", "射手座", "摩羯座", "水瓶座", "双鱼座", ] as const; const FROZEN_D9 = { 白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" }, 金牛座: { trait: "稳定、务实、占有欲强", spouse: "稳重、有经济基础", marriage: "晚婚、稳定型" }, 双子座: { trait: "沟通、多变、好奇心强", spouse: "聪明、善交流", marriage: "多次恋爱、友谊型" }, 巨蟹座: { trait: "情感丰富、家庭导向", spouse: "温柔、顾家", marriage: "家庭型" }, 狮子座: { trait: "骄傲、戏剧性、领导欲", spouse: "有魅力、有地位", marriage: "戏剧性" }, 处女座: { trait: "完美主义、挑剔、服务型", spouse: "细致、有技能", marriage: "晚婚、服务型" }, 天秤座: { trait: "和谐、美感、合作", spouse: "优雅、有艺术气质", marriage: "美满、合作型" }, 天蝎座: { trait: "深刻、占有欲、转化", spouse: "神秘、有深度", marriage: "深刻、转化型" }, 射手座: { trait: "自由、哲学、冒险", spouse: "开放、有学识", marriage: "自由型、精神伴侣" }, 摩羯座: { trait: "务实、责任、延迟", spouse: "成熟、有事业", marriage: "晚婚、责任型" }, 水瓶座: { trait: "独立、非传统、友谊", spouse: "独特、有理想", marriage: "非传统、友谊型" }, 双鱼座: { trait: "浪漫、牺牲、灵性", spouse: "灵性、有艺术天赋", marriage: "灵性、牺牲型" }, } as const; const FROZEN_D10 = { 白羊座: { trait: "领导、创业、竞争", occupation: "创业者、运动员、军人", style: "主动、竞争" }, 金牛座: { trait: "稳定、财富、艺术", occupation: "金融、艺术、农业", style: "稳定、务实" }, 双子座: { trait: "沟通、写作、教育", occupation: "教师、作家、销售", style: "多变、沟通" }, 巨蟹座: { trait: "照顾、家庭、情感", occupation: "护理、餐饮、房地产", style: "照顾、情感" }, 狮子座: { trait: "领导、表演、创意", occupation: "管理、娱乐、政治", style: "领导、表演" }, 处女座: { trait: "服务、分析、健康", occupation: "医疗、分析、服务", style: "细致、服务" }, 天秤座: { trait: "合作、美学、法律", occupation: "法律、艺术、咨询", style: "合作、和谐" }, 天蝎座: { trait: "研究、转化、危机", occupation: "研究、心理学、危机管理", style: "深度、转化" }, 射手座: { trait: "教育、哲学、国际", occupation: "教育、出版、国际事务", style: "自由、哲学" }, 摩羯座: { trait: "管理、责任、延迟", occupation: "管理、政府、建筑", style: "务实、责任" }, 水瓶座: { trait: "创新、科技、人道", occupation: "科技、人道主义、创新", style: "创新、独立" }, 双鱼座: { trait: "灵性、艺术、服务", occupation: "艺术、灵性、医疗", style: "灵性、服务" }, } as const; const FROZEN_EXISTENCE = [ { label: "明确发生且时间吻合", answer_class: "yes" }, { label: "发生过但程度较弱", answer_class: "weak_yes" }, { label: "明确没有发生", answer_class: "no" }, { label: "这段记不清楚", answer_class: "unsure" }, ] as const; const FROZEN_QUALITY = [ { label: "发挥明显失常或压力很大", answer_class: "yes" }, { label: "有压力但不算明显失常", answer_class: "weak_yes" }, { label: "发挥正常、没有明显失常", answer_class: "no" }, { label: "这段记不清楚", answer_class: "unsure" }, ] as const; const YEAR_OR_SPAN = /\d{4}|这段/; function styleCard( domain: "relationship" | "career", signs: readonly [string, string, string], labelFor: (sign: string) => string, ): ReturnType { const probe: DiscriminatingEventProbe = { year: 2024, year_label: "2024–2026 年这段里", domain, event_family: domain === "relationship" ? "相处方式更接近其中一种" : "做事风格更接近其中一种", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: "对照类型表只作校时方法,不是命运承诺。", role: "distinguish", information_gain: 1.2, candidate_ids: ["05:00", "05:03", "05:04"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:03", "05:04"] }, { answer_class: "weak_yes", supports: ["05:03"], conflicts: ["05:00", "05:04"] }, { answer_class: "no", supports: ["05:04"], conflicts: ["05:00", "05:03"] }, ], choice_kind: "varga_style", semantic_key: domain === "relationship" ? "varga.d9.05:00|05:03/05:04" : "varga.d10.05:00|05:03/05:04", style_options: [ { sign: signs[0], label: labelFor(signs[0]), answer_class: "yes" }, { sign: signs[1], label: labelFor(signs[1]), answer_class: "weak_yes" }, { sign: signs[2], label: labelFor(signs[2]), answer_class: "no" }, ], }; return buildChoiceFrame({ method_id: domain === "relationship" ? "d9_refine" : "d10_career", ask_theme: domain === "relationship" ? "relationship_style" : "career_style", domain, user_prompt_hint: "unused", choice_kind: "varga_style", semantic_key: probe.semantic_key, style_options: probe.style_options, }, { evidence: [ { status: "confirmed", domain, datePrecision: "year", occurredFrom: "2024-01-01", occurredTo: null }, { status: "confirmed", domain, datePrecision: "year", occurredFrom: "2026-01-01", occurredTo: null }, ], probes: [probe], }); } test("D9/D10 interpretation fields stay frozen; userChoice is the card label", () => { assert.deepEqual(Object.keys(D9_TYPE_TABLE), [...SIGNS]); assert.deepEqual(Object.keys(D10_TYPE_TABLE), [...SIGNS]); for (const sign of SIGNS) { const d9 = D9_TYPE_TABLE[sign]; const d10 = D10_TYPE_TABLE[sign]; assert.ok(d9); assert.ok(d10); assert.deepEqual({ trait: d9.trait, spouse: d9.spouse, marriage: d9.marriage }, FROZEN_D9[sign]); assert.deepEqual( { trait: d10.trait, occupation: d10.occupation, style: d10.style }, FROZEN_D10[sign], ); assert.ok(d9.userChoice.trim().length >= LABEL_MIN && d9.userChoice.length <= LABEL_MAX); assert.ok(d10.userChoice.trim().length >= LABEL_MIN && d10.userChoice.length <= LABEL_MAX); assert.notEqual(d9.userChoice, d9.trait); assert.notEqual(d10.userChoice, d10.style); assert.notEqual(d10.userChoice, d10.trait); assert.equal(d9StyleLabel(sign), d9.userChoice); assert.equal(d10StyleLabel(sign), d10.userChoice); assert.equal(d9StyleLabel(sign.replace(/座$/, "")), d9.userChoice); assert.equal(d10StyleLabel(sign.replace(/座$/, "")), d10.userChoice); } assert.equal(d9StyleLabel("天秤座"), "尽量避免冲突,凡事想商量着来"); assert.equal(d9StyleLabel("天蝎座"), "投入很深、在意独占,也容易大起大落"); assert.equal(d9StyleLabel("射手座"), "看重各自的空间,不喜欢被绑太紧"); assert.equal(d10StyleLabel("巨蟹座"), "做事以照顾人为主,在意团队里的感受"); assert.equal(d10StyleLabel("狮子座"), "习惯带头,也不排斥站到台前"); assert.equal(d10StyleLabel("处女座"), "盯细节、把事做扎实,偏后端支持"); }); test("varga_style options keep sign → answer_class mapping and are renderable", () => { const d9 = completeStyleOptions({ choiceKind: "varga_style", styleOptions: [ { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, { sign: "射手座", label: d9StyleLabel("射手座"), answer_class: "no" }, ], }); assert.equal(d9.ok, true); if (!d9.ok) return; assert.equal(isRenderableStyleOptions(d9.options), true); assert.equal(d9.options.find((item) => item.answer_class === "yes")?.sign, "天秤座"); assert.equal(d9.options.find((item) => item.answer_class === "weak_yes")?.sign, "天蝎座"); assert.equal(d9.options.find((item) => item.answer_class === "no")?.sign, "射手座"); assert.equal(d9.options.find((item) => item.answer_class === "yes")?.label, D9_TYPE_TABLE.天秤座.userChoice); assert.notEqual(d9.options.find((item) => item.answer_class === "yes")?.label, D9_TYPE_TABLE.天秤座.trait); const no = d9.options.find((item) => item.answer_class === "no")?.label ?? ""; const unsure = d9.options.find((item) => item.answer_class === "unsure")?.label ?? ""; assert.doesNotMatch(no, /特质/); assert.doesNotMatch(unsure, /特质|这段/); assert.equal(unsure, "一时说不好"); const d10 = completeStyleOptions({ choiceKind: "varga_style", styleOptions: [ { sign: "巨蟹座", label: d10StyleLabel("巨蟹座"), answer_class: "yes" }, { sign: "狮子座", label: d10StyleLabel("狮子座"), answer_class: "weak_yes" }, { sign: "处女座", label: d10StyleLabel("处女座"), answer_class: "no" }, ], }); assert.equal(d10.ok, true); if (!d10.ok) return; assert.equal(isRenderableStyleOptions(d10.options), true); assert.equal(d10.options.find((item) => item.answer_class === "yes")?.sign, "巨蟹座"); assert.equal(d10.options.find((item) => item.answer_class === "weak_yes")?.sign, "狮子座"); assert.equal(d10.options.find((item) => item.answer_class === "no")?.sign, "处女座"); assert.notEqual(d10.options.find((item) => item.answer_class === "yes")?.label, D10_TYPE_TABLE.巨蟹座.style); const filled = completeStyleOptions({ choiceKind: "varga_style", styleOptions: [ { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, ], }); assert.equal(filled.ok, true); if (!filled.ok) return; assert.equal(filled.options.find((item) => item.answer_class === "no")?.label, "都不太像我"); assert.equal(filled.options.find((item) => item.answer_class === "unsure")?.label, "一时说不好"); assert.doesNotMatch(filled.options.find((item) => item.answer_class === "no")?.label ?? "", /特质/); assert.doesNotMatch(filled.options.find((item) => item.answer_class === "unsure")?.label ?? "", /这段/); }); test("existence and event_quality fixed options stay on the old copy", () => { assert.deepEqual(EXISTENCE_STYLE_OPTIONS, [...FROZEN_EXISTENCE]); assert.deepEqual(QUALITY_STYLE_OPTIONS, [...FROZEN_QUALITY]); const existence = completeStyleOptions({ choiceKind: "existence" }); const quality = completeStyleOptions({ choiceKind: "event_quality" }); assert.equal(existence.ok && quality.ok, true); if (!existence.ok || !quality.ok) return; assert.deepEqual(existence.options, [...FROZEN_EXISTENCE]); assert.deepEqual(quality.options, [...FROZEN_QUALITY]); assert.equal(existence.options.find((item) => item.answer_class === "unsure")?.label, "这段记不清楚"); assert.equal(quality.options.find((item) => item.answer_class === "unsure")?.label, "这段记不清楚"); }); test("varga_style stems are long-term; existence and quality still lock the year", () => { const relationship = styleCard("relationship", ["天秤座", "天蝎座", "射手座"], d9StyleLabel); const career = styleCard("career", ["巨蟹座", "狮子座", "处女座"], d10StyleLabel); assert.ok(relationship); assert.ok(career); const relationshipCopy = relationship ? serverOwnedChoiceCopy(relationship) : null; const careerCopy = career ? serverOwnedChoiceCopy(career) : null; assert.ok(relationshipCopy); assert.ok(careerCopy); assert.doesNotMatch(relationship!.prompt, YEAR_OR_SPAN); assert.doesNotMatch(career!.prompt, YEAR_OR_SPAN); assert.doesNotMatch(relationship!.period, YEAR_OR_SPAN); assert.doesNotMatch(career!.period, YEAR_OR_SPAN); assert.equal(relationship!.prompt, "亲密关系里,你更接近哪一种相处方式?"); assert.equal(career!.prompt, "平时做事,你更接近下面哪一种?"); assert.doesNotMatch(relationshipCopy!.prompt, YEAR_OR_SPAN); assert.doesNotMatch(careerCopy!.prompt, YEAR_OR_SPAN); assert.match(relationship!.why, /校时方法/); assert.match(career!.why, /校时方法/); const existence = buildChoiceFrame({ method_id: "d4_home", ask_theme: "home_change", domain: "relocation", user_prompt_hint: "unused", choice_kind: "existence", }, { probes: [{ year: 2016, year_label: "2016 年前后", domain: "relocation", event_family: "搬家、离乡或长期异地", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: "年份锁定 2016 年前后。", role: "distinguish", information_gain: 0.4, candidate_ids: ["05:00", "05:20"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, ], }], }); assert.ok(existence); assert.match(existence!.prompt, /2016 年前后/); assert.match(existence!.prompt, /搬家、离乡或长期异地/); assert.equal(existence!.unsure_label, "这段记不清楚"); const quality = buildChoiceFrame({ method_id: "d24_learn", ask_theme: "education_style", domain: "education", user_prompt_hint: "unused", choice_kind: "event_quality", }, { probes: [{ year: 2020, year_label: "2020 年前后", domain: "education", event_family: "学业或考试发挥失常、压力特别大", source: "known_event_quality", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: "年份锁定 2020 年前后。", role: "distinguish", information_gain: 0.5, candidate_ids: ["05:00", "05:20"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, ], choice_kind: "event_quality", }], }); assert.ok(quality); assert.match(quality!.prompt, /2020 年前后/); assert.equal(quality!.unsure_label, "这段记不清楚"); }); test("remaining D9/D10 packets use userChoice labels and long-term questions", () => { const d9 = selectDiscriminatorProbe(buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", candidateTimes: ["05:00", "05:03", "05:04"], transitions: [ { layer: "d9", at: "05:03", from_sign: "天秤座", to_sign: "天蝎座" }, { layer: "d9", at: "05:04", from_sign: "天蝎座", to_sign: "射手座" }, ], })); assert.ok(d9); assert.equal(d9!.choiceKind, "varga_style"); assert.doesNotMatch(d9!.question, YEAR_OR_SPAN); assert.equal(d9!.question, "亲密关系里,你更接近哪一种相处方式?"); assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.sign, "天秤座"); assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "weak_yes")?.sign, "天蝎座"); assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "no")?.sign, "射手座"); assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.label, d9StyleLabel("天秤座")); assert.notEqual(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D9_TYPE_TABLE.天秤座.trait); const d10 = selectDiscriminatorProbe(buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", candidateTimes: ["05:00", "05:03", "05:04"], transitions: [ { layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" }, { layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" }, ], })); assert.ok(d10); assert.equal(d10!.choiceKind, "varga_style"); assert.doesNotMatch(d10!.question, YEAR_OR_SPAN); assert.equal(d10!.question, "平时做事,你更接近下面哪一种?"); assert.equal(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, d10StyleLabel("巨蟹座")); assert.notEqual(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D10_TYPE_TABLE.巨蟹座.style); }); test("choice-card legend is screen-reader only so the stem is not duplicated", () => { const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"); const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? ""; const srOnlyLegend = globals.match( /\.rectification-choice-card \.birth-time-choice-question legend\.sr-only \{[^}]+\}/, )?.[0] ?? ""; assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/); assert.match(srOnlyLegend, /margin:\s*0/); assert.match(card, /\{props\.card\.prompt\}<\/legend>/); assert.match(card, /className="rectification-choice-why"/); assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/); });