fix: bind rectification state to current declaration
This commit is contained in:
@@ -5,9 +5,13 @@ import {
|
||||
accountProfilePatchSchema,
|
||||
resolveAccountBirthTimeApplicationPatch,
|
||||
} from "../src/lib/account-profile-patch.ts";
|
||||
import {
|
||||
resolveAccountRectificationCase,
|
||||
} from "../src/lib/account-rectification-case.ts";
|
||||
|
||||
const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
|
||||
const patchSource = readFileSync(new URL("../src/lib/account-profile-patch.ts", import.meta.url), "utf8");
|
||||
const caseServiceSource = readFileSync(new URL("../src/lib/account-rectification-case.ts", import.meta.url), "utf8");
|
||||
|
||||
test("account API reads and returns the server-configured rectification price", () => {
|
||||
assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/);
|
||||
@@ -16,24 +20,197 @@ test("account API reads and returns the server-configured rectification price",
|
||||
});
|
||||
|
||||
test("account API projects only the minimum case state needed by the homepage", () => {
|
||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
|
||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
|
||||
|
||||
assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at/);
|
||||
assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at/);
|
||||
assert.doesNotMatch(caseSelect, /candidate_scan|event_evidence|validation_receipt|pending_consultation_question|journey_snapshot|turn_state/);
|
||||
assert.match(source, /caseId:/);
|
||||
assert.match(source, /journeyProtocol:/);
|
||||
assert.match(source, /turnVersion:/);
|
||||
assert.match(source, /preservesActiveTime:/);
|
||||
assert.match(caseServiceSource, /caseId:/);
|
||||
assert.match(caseServiceSource, /journeyProtocol:/);
|
||||
assert.match(caseServiceSource, /turnVersion:/);
|
||||
assert.match(caseServiceSource, /preservesActiveTime:/);
|
||||
});
|
||||
|
||||
test("account API scopes the service-role case lookup to the authenticated account", () => {
|
||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
|
||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
|
||||
|
||||
assert.match(caseSelect, /\.eq\("user_id", user\.id\)/);
|
||||
assert.match(caseSelect, /\.eq\("journey_protocol", "conversational-evidence-v3"\)/);
|
||||
assert.match(caseSelect, /\.order\("updated_at", \{ ascending: false \}\)/);
|
||||
});
|
||||
|
||||
const currentDeclaredProfile = Object.freeze({
|
||||
credits: 7,
|
||||
active_birth_time: null,
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
birth_date: "1997-08-08",
|
||||
reported_birth_time: "05:30:00",
|
||||
birth_time_source: "approximate",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: "家人记得天亮前后",
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
country_code: "CN",
|
||||
province_code: "130000",
|
||||
city_code: "130400",
|
||||
district_code: "130406",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_offset: 8,
|
||||
});
|
||||
|
||||
const currentDeclaredInput = Object.freeze({
|
||||
source: "approximate",
|
||||
birthDate: "1997-08-08",
|
||||
reportedTime: "05:30",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
birthTimeClue: "家人记得天亮前后",
|
||||
birthplace: {
|
||||
countryCode: "CN",
|
||||
provinceCode: "130000",
|
||||
cityCode: "130400",
|
||||
districtCode: "130406",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezoneOffset: 8,
|
||||
},
|
||||
});
|
||||
|
||||
function unfinishedCase(
|
||||
declaredBirthInput: unknown = currentDeclaredInput,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
journey_protocol: "conversational-evidence-v3",
|
||||
status: "paused",
|
||||
turn_version: 4,
|
||||
revision_of_case_id: null,
|
||||
baseline_active_time: null,
|
||||
declared_birth_input: declaredBirthInput,
|
||||
private_candidate: { calculationVersion: "must-not-leak" },
|
||||
pending_consultation_question: "must-not-leak",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("account case projection resumes only an unfinished v3 case matching the current declaration", () => {
|
||||
const projected = resolveAccountRectificationCase(
|
||||
currentDeclaredProfile,
|
||||
[unfinishedCase()],
|
||||
);
|
||||
|
||||
assert.deepEqual(projected, {
|
||||
caseId: "11111111-1111-4111-8111-111111111111",
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "paused",
|
||||
turnVersion: 4,
|
||||
isRevision: false,
|
||||
preservesActiveTime: false,
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(projected),
|
||||
/declared|private_candidate|pending_consultation_question|must-not-leak/,
|
||||
);
|
||||
assert.equal(currentDeclaredProfile.rectification_case_id, null);
|
||||
});
|
||||
|
||||
test("edited declaration fields make old unfinished cases non-resumable without deleting audit rows", () => {
|
||||
const declarationMismatches = [
|
||||
{ ...currentDeclaredInput, birthDate: "1997-08-09" },
|
||||
{ ...currentDeclaredInput, reportedTime: "05:31" },
|
||||
{ ...currentDeclaredInput, birthTimeClue: "另一条线索" },
|
||||
{ ...currentDeclaredInput, uncertaintyBeforeMinutes: 60, uncertaintyAfterMinutes: 60 },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, countryCode: "TW" } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, provinceCode: "140000" } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, cityCode: "130500" } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, districtCode: "130407" } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, latitude: 36.420488 } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, longitude: 114.209937 } },
|
||||
{ ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, timezoneOffset: 9 } },
|
||||
];
|
||||
|
||||
for (const declared of declarationMismatches) {
|
||||
const row = unfinishedCase(declared);
|
||||
assert.equal(resolveAccountRectificationCase(currentDeclaredProfile, [row]), null);
|
||||
assert.equal(row.declared_birth_input, declared, "matching must not mutate or delete audit data");
|
||||
}
|
||||
|
||||
const periodProfile = {
|
||||
...currentDeclaredProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_period: "early_morning",
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: null,
|
||||
uncertainty_after_minutes: null,
|
||||
};
|
||||
assert.equal(resolveAccountRectificationCase(periodProfile, [unfinishedCase()]), null);
|
||||
const periodDeclaration = {
|
||||
source: "period_only",
|
||||
birthDate: currentDeclaredInput.birthDate,
|
||||
reportedPeriod: "early_morning",
|
||||
birthTimeClue: null,
|
||||
birthplace: currentDeclaredInput.birthplace,
|
||||
};
|
||||
assert.ok(resolveAccountRectificationCase(periodProfile, [unfinishedCase(periodDeclaration)]));
|
||||
assert.equal(resolveAccountRectificationCase(periodProfile, [unfinishedCase({
|
||||
...periodDeclaration,
|
||||
reportedPeriod: "morning",
|
||||
})]), null);
|
||||
assert.equal(resolveAccountRectificationCase(currentDeclaredProfile, [
|
||||
unfinishedCase(currentDeclaredInput, { status: "completed" }),
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("account case matching validates optional canonical place labels and can find a later matching row", () => {
|
||||
const wrongLabel = unfinishedCase({
|
||||
...currentDeclaredInput,
|
||||
birthplace: { ...currentDeclaredInput.birthplace, city: "错误地点" },
|
||||
});
|
||||
const matching = unfinishedCase(currentDeclaredInput, {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "active",
|
||||
turn_version: 1,
|
||||
});
|
||||
const correctlyLabelled = unfinishedCase({
|
||||
...currentDeclaredInput,
|
||||
birthplace: {
|
||||
...currentDeclaredInput.birthplace,
|
||||
city: "中国 · 河北省 · 邯郸市 · 峰峰矿区",
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(resolveAccountRectificationCase(currentDeclaredProfile, [correctlyLabelled]));
|
||||
|
||||
assert.deepEqual(
|
||||
resolveAccountRectificationCase(currentDeclaredProfile, [wrongLabel, matching]),
|
||||
{
|
||||
caseId: "22222222-2222-4222-8222-222222222222",
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "active",
|
||||
turnVersion: 1,
|
||||
isRevision: false,
|
||||
preservesActiveTime: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("account route reads declaration truth only for server matching and does not key resume to profile case id", () => {
|
||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
|
||||
const responseStart = source.indexOf("return NextResponse.json({\n user:");
|
||||
const responseProjection = source.slice(responseStart, source.indexOf(" } catch", responseStart));
|
||||
|
||||
assert.match(source, /birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset/);
|
||||
assert.match(caseSelect, /declared_birth_input/);
|
||||
assert.match(caseSelect, /\.eq\("user_id", user\.id\)/);
|
||||
assert.match(caseSelect, /\.in\("status",/);
|
||||
assert.doesNotMatch(caseSelect, /rectification_case_id/);
|
||||
assert.match(source, /resolveAccountRectificationCase/);
|
||||
assert.doesNotMatch(responseProjection, /declared_birth_input|private_candidate|pending_consultation_question/);
|
||||
});
|
||||
|
||||
test("profile patch schema validates calendar, clock, source requirements, and location bounds", () => {
|
||||
const valid = {
|
||||
name: "岳辰",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
canUseUnverifiedBirthTime,
|
||||
birthTimeConsultationOptionsCopy,
|
||||
consultationModeForSession,
|
||||
createLatestAccountRequestGuard,
|
||||
createBirthTimeConsultationConsentState,
|
||||
@@ -60,6 +61,11 @@ test("period-only and unknown declarations never pretend to provide an unverifie
|
||||
assert.equal(canUseUnverifiedBirthTime(unknown), false);
|
||||
assert.equal(requiresBirthTimeConsent(periodOnly), false);
|
||||
assert.equal(requiresBirthTimeConsent(unknown), false);
|
||||
assert.match(birthTimeConsultationOptionsCopy(periodOnly), /一般咨询.*校正/);
|
||||
assert.match(birthTimeConsultationOptionsCopy(unknown), /一般咨询.*校正/);
|
||||
assert.doesNotMatch(birthTimeConsultationOptionsCopy(periodOnly), /使用.*原始填报时间|具体原始时间/);
|
||||
assert.doesNotMatch(birthTimeConsultationOptionsCopy(unknown), /使用.*原始填报时间|具体原始时间/);
|
||||
assert.match(birthTimeConsultationOptionsCopy(reportedExactTime), /原始填报时间.*校正/);
|
||||
});
|
||||
|
||||
test("the current reported minute wins over an old candidate and never falls back to it", () => {
|
||||
@@ -198,3 +204,23 @@ test("soft choice announces itself and locks every action while rectification op
|
||||
assert.match(source, /继续不依赖出生分钟的一般咨询/);
|
||||
assert.ok((source.match(/disabled=\{pending\}/g) ?? []).length >= 3);
|
||||
});
|
||||
|
||||
test("homepage and profile result copy use the source-aware consultation options", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(page, /birthTimeConsultationOptionsCopy\(profileDraft\)/);
|
||||
assert.match(page, /birthTimeConsultationOptionsCopy\(profile\)/);
|
||||
assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/);
|
||||
});
|
||||
|
||||
test("a saved declaration edit cannot leave the old resumable case in local account state", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const saveProfile = page.slice(
|
||||
page.indexOf("async function saveProfile"),
|
||||
page.indexOf("async function saveOnboardingName"),
|
||||
);
|
||||
|
||||
assert.match(saveProfile, /declarationChanged[\s\S]*setAccount\(\(current\)[\s\S]*rectificationCase:\s*null/);
|
||||
assert.match(saveProfile, /declarationChanged[\s\S]*void refreshAccount\(\)/);
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
|
||||
assert.match(pageSource, /未确认;咨询时仅可临时使用原始填报时间/);
|
||||
assert.match(pageSource, /未确认;\$\{birthTimeConsultationOptionsCopy\(profile\)\}/);
|
||||
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
|
||||
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
|
||||
});
|
||||
|
||||
@@ -256,5 +256,5 @@ test("candidate copy does not claim an unconfirmed minute is automatically in us
|
||||
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /已用于当前排盘/);
|
||||
assert.match(source, /普通咨询只能临时使用上面的原始填报时间/);
|
||||
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { guardPreciseTimingOutput } from "../src/lib/timing-output-guard.ts";
|
||||
import {
|
||||
GENERAL_NO_BIRTH_TIME_REFUSAL,
|
||||
guardGeneralNoBirthTimeOutput,
|
||||
guardPreciseTimingOutput,
|
||||
} from "../src/lib/timing-output-guard.ts";
|
||||
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
|
||||
import { parseAgentReply } from "../src/lib/agent-reply.ts";
|
||||
import { createBirthTimeModeOutputGuard } from "../src/lib/consultation-birth-time-mode.ts";
|
||||
@@ -72,3 +76,63 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu
|
||||
assert.deepEqual(parsed.suggestions, ["你一定会升职吗?", "D9 是什么?", "先完成生时校正"]);
|
||||
assert.equal(parsed.title, "一般占星咨询");
|
||||
});
|
||||
|
||||
test("general mode structurally rejects personalized chart placements in Chinese and English", () => {
|
||||
const unsafeClaims = [
|
||||
"你的七宫落入摩羯。",
|
||||
"盘面显示你的事业宫很强。",
|
||||
"你的金星落第七宫。",
|
||||
"你的上升落在巨蟹座。",
|
||||
"你的 D9 显示婚姻会晚一些。",
|
||||
"Your Venus is in the 7th house.",
|
||||
"Your ascendant falls in Cancer.",
|
||||
"Your D9 chart shows a strong marriage house.",
|
||||
];
|
||||
|
||||
for (const claim of unsafeClaims) {
|
||||
const guarded = guardGeneralNoBirthTimeOutput(claim);
|
||||
assert.equal(guarded.includes(GENERAL_NO_BIRTH_TIME_REFUSAL), true, claim);
|
||||
assert.equal(guarded.includes(claim.replace(/[。.]$/, "")), false, claim);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("第七宫在占星概念中常与关系相关。"),
|
||||
"第七宫在占星概念中常与关系相关。",
|
||||
);
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("Venus is generally associated with relating and values."),
|
||||
"Venus is generally associated with relating and values.",
|
||||
);
|
||||
assert.equal(
|
||||
guardGeneralNoBirthTimeOutput("你问的第七宫,在占星概念中常与关系相关。"),
|
||||
"你问的第七宫,在占星概念中常与关系相关。",
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden AYANAM comments cannot split a personalized claim around the guard", async () => {
|
||||
const title = "<!--AYANAM_TITLE:一般占星咨询-->";
|
||||
const suggestions = '<!--AYANAM_SUGGESTIONS:["了解第七宫的一般概念","先完成生时校正","改问一般知识"]-->';
|
||||
async function* reply() {
|
||||
yield "一般知识可以说明概念。你的<!";
|
||||
yield "--AYANAM_TITLE:一般占星咨询--";
|
||||
yield ">金星落";
|
||||
yield "第七宫。\n<!--AYANAM_SUGGEST";
|
||||
yield 'IONS:["了解第七宫的一般概念","先完成生时校正","改问一般知识"]-->';
|
||||
}
|
||||
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000097",
|
||||
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = parseAgentReply(text, "general");
|
||||
|
||||
assert.match(text, /一般知识可以说明概念/);
|
||||
assert.match(text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
|
||||
assert.doesNotMatch(text, /你的\s*金星落第七宫/);
|
||||
assert.equal(text.includes(title), true);
|
||||
assert.equal(text.includes(suggestions), true);
|
||||
assert.equal(parsed.title, "一般占星咨询");
|
||||
assert.deepEqual(parsed.suggestions, ["了解第七宫的一般概念", "先完成生时校正", "改问一般知识"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user