95f5851261
Choice cards were showing type-table tokens as options, dating long-term style questions, and stacking the stem on the first option because legend is outside the fieldset grid. Co-authored-by: Cursor <cursoragent@cursor.com>
351 lines
18 KiB
TypeScript
351 lines
18 KiB
TypeScript
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<typeof buildChoiceFrame> {
|
||
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 spacing does not depend on fieldset grid gap", () => {
|
||
const choiceCss = readFileSync(new URL("../src/app/birth-time-choice.css", import.meta.url), "utf8");
|
||
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 legendRule = choiceCss.match(/\.birth-time-choice-question legend \{[^}]+\}/)?.[0] ?? "";
|
||
const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? "";
|
||
assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||
assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
|
||
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
||
assert.match(card, /className="rectification-choice-why"/);
|
||
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
|
||
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>\s*\{props\.card\.why \? <p className="rectification-choice-why">/);
|
||
});
|