Files
Jyotisha/frontend/tests/rectification-midnight-opening-route.test.ts
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
2026-09-21 02:55:00 +08:00

152 lines
10 KiB
TypeScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
// Route + stateful persistence fake: not a real database or browser test.
// The idle interview, midnight override, focus writer, runner and NDJSON mapping are real.
test("opening persists and reloads the midnight-side question before rendering NDJSON", () => {
const script = String.raw`
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { pathToFileURL } from 'node:url';
import { CASE_ID, SESSION_ID, USER_ID, TURN_ID, FOCUS_ID, dossierFixture,
computeFixture, conversationSummaryFixture, receiptHandlers } from './tests/rectification-v9-test-support.ts';
import { parseAgentChoiceCopy } from './src/lib/rectification-agentic/v9/choice-card.ts';
import { loadV9CaseDossier } from './src/lib/rectification-agentic/v9/tool-service.ts';
import { safePublicEvent } from './src/lib/rectification-agentic/v9/stream-mapping.ts';
import { windowFromBlockChoice } from './src/lib/rectification-agentic/v9/block-scan.ts';
const range = { start_time: '23:00', end_time: '03:59', midnight_side_pending: true,
candidate_intervals: [
{ start_at: '2000-06-15T00:00', end_at: '2000-06-15T03:59' },
{ start_at: '2000-06-15T23:00', end_at: '2000-06-15T23:59' }
] };
let focus = null;
let reloadsAfterWrite = 0;
const calls = [];
const dossier = () => dossierFixture({ stage: 'block_scan', candidateRange: range,
evidence: [], evidenceCount: 0, turns: [], turnCount: 0, latestResult: null,
conversationSummary: conversationSummaryFixture({ activeFocus: focus }) });
const initial = dossier();
assert.equal(initial.case.stage, 'block_scan');
assert.equal(initial.case.candidate_range.midnight_side_pending, true);
assert.deepEqual(initial.case.candidate_range.candidate_intervals, range.candidate_intervals);
assert.equal(initial.latest_result, null);
assert.equal(initial.conversation_summary.active_focus, null);
assert.deepEqual(initial.evidence, []);
const accounting = { rpc: async (fn, args = {}) => {
calls.push({ fn, args });
let data;
if (fn === 'get_agentic_rectification_case') data = dossier().case;
else if (fn === 'get_agentic_rectification_case_dossier') {
if (focus) reloadsAfterWrite++;
data = dossier();
} else if (fn === 'get_agentic_rectification_case_compute') data = {
...computeFixture({ baselineBirthSnapshot: { birth_date: '2000-06-15', timezone_offset: 8,
birth_time_source: 'period_only', birth_time_period: 'late_night', reported_birth_time: null } }),
candidate_range: range,
};
else if (fn === 'set_agentic_rectification_conversation_focus') {
focus = { id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id,
intent: args.p_intent, target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain, target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema, asked_turn_id: args.p_asked_turn_id,
status: 'active', asked_at: '2026-09-20T00:00:00Z', resolved_at: null, answer_option: null };
data = { focus, idempotent: false };
} else if (fn === 'append_agentic_rectification_turn') data = { turn_id: TURN_ID, idempotent: false };
else if (fn === 'finalize_agentic_rectification_turn') data = { turn_id: TURN_ID, status: args.p_status, idempotent: false };
else if (receiptHandlers[fn]) data = await receiptHandlers[fn](fn, args);
else throw new Error('Unexpected persistence RPC: ' + fn);
return { data: structuredClone(data), error: null };
} };
mock.module('server-only', { namedExports: {} });
mock.module('@/lib/supabase/server', { namedExports: { createServerSupabaseClient: async () => ({
auth: { getUser: async () => ({ data: { user: { id: USER_ID } }, error: null }) },
from: () => ({ select() { return this; }, eq() { return this; },
maybeSingle: async () => ({ data: { id: SESSION_ID, session_type: 'birth_time_rectification',
agentic_rectification_case_id: CASE_ID, model_id: 'synthetic', model_config_version: 1 }, error: null }) })
}) } });
mock.module('@/lib/supabase/admin', { namedExports: { createAdminSupabaseClient: () => accounting } });
mock.module('@/lib/product-access', { namedExports: { isProductEnabled: async () => true } });
mock.module('@/lib/feature-flags', { namedExports: { loadRuntimeFeatureFlags: async () => new Map([
['rectification_runtime_version', { enabled: true }]
]) } });
mock.module('@/lib/model-catalog', { namedExports: { resolveSessionLanguageModel: async () => ({
id: 'synthetic', configVersion: 1, model: {}
}) } });
mock.module('@/mastra/agentic-rectification', { namedExports: { getRectificationV9Agent: async () => ({
getSkill: async () => ({ name: 'jyotish-birth-time-rectification', instructions: 'synthetic model boundary' }),
stream: async () => ({ fullStream: (async function* () {
yield { type: 'start' };
yield { type: 'tool-call', payload: { toolName: 'rectification-read-case', args: { caseId: CASE_ID } } };
const loaded = await loadV9CaseDossier(accounting, USER_ID, CASE_ID);
assert.equal(loaded.conversationSummary.activeFocus?.questionId, 'probe:block_scan.midnight_side');
yield { type: 'tool-result', payload: { toolName: 'rectification-read-case', result: loaded } };
yield { type: 'text-delta', payload: { text: '请先选择午夜前后。' } };
yield { type: 'finish', payload: { finishReason: 'stop' } };
})(), totalUsage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }) })
}) } });
const { POST } = await import(pathToFileURL(process.cwd() + '/src/app/api/rectification/agent/route.ts').href);
const response = await POST(new Request('https://example.invalid/api/rectification/agent', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ caseId: CASE_ID, sessionId: SESSION_ID, requestId: TURN_ID, action: 'opening' })
}));
const text = await response.text(); // Consume the actual NDJSON stream through EOF.
assert.equal(response.status, 200, text);
const events = text.trim().split('\n').map(line => JSON.parse(line));
assert.ok(focus, JSON.stringify({ events, calls }));
assert.equal(focus.intent, 'choose_birth_block');
assert.equal(focus.question_id, 'probe:block_scan.midnight_side');
assert.equal(focus.expected_answer_schema.probe_id, focus.question_id);
assert.equal(focus.expected_answer_schema.semantic_key, 'block_scan.midnight_side');
assert.equal(focus.expected_answer_schema.midnight_side, true);
assert.equal(focus.expected_answer_schema.scoring, false);
assert.equal(focus.expected_answer_schema.choice_kind, 'block_choice');
assert.ok(reloadsAfterWrite > 0);
const loaded = await loadV9CaseDossier(accounting, USER_ID, CASE_ID);
const choice = parseAgentChoiceCopy(loaded.conversationSummary.activeFocus.expectedAnswerSchema);
assert.ok(choice, JSON.stringify(loaded.conversationSummary.activeFocus));
assert.match(choice.prompt, /午夜前.*午夜后/);
assert.deepEqual(choice.options.map(option => option.key), ['A', 'B', 'C', 'D']);
const schema = focus.expected_answer_schema;
const late = [{ start_at: '2000-06-15T23:00', end_at: '2000-06-15T23:59' }];
const early = [{ start_at: '2000-06-15T00:00', end_at: '2000-06-15T03:59' }];
assert.deepEqual(schema.block_periods.A.candidate_intervals, late);
assert.deepEqual(schema.block_periods.B.candidate_intervals, early);
assert.deepEqual(schema.block_periods.C.candidate_intervals, range.candidate_intervals);
for (const [optionId, intervals, start, end] of [
['A', late, '23:00', '23:59'], ['B', early, '00:00', '03:59'],
['C', range.candidate_intervals, '23:00', '03:59'],
['D', range.candidate_intervals, '23:00', '03:59'],
]) {
const selected = windowFromBlockChoice({ schema, optionId });
assert.deepEqual(selected.candidate_intervals, intervals, optionId);
assert.equal(selected.start_time, start, optionId);
assert.equal(selected.end_time, end, optionId);
assert.equal(selected.midnight_side_pending, false, optionId);
}
assert.ok(events.some(event => event.type === 'run.completed'), JSON.stringify({ events, calls }));
assert.equal(events.some(event => ['run.failed', 'error', 'attempt.reset'].includes(event.type)), false, JSON.stringify(events));
assert.equal(calls.filter(call => call.fn === 'create_agentic_rectification_run_attempt').length, 1);
assert.equal(calls.findLast(call => call.fn === 'finalize_agentic_rectification_turn')?.args.p_status, 'completed');
const writes = calls.filter(call => call.fn === 'set_agentic_rectification_conversation_focus');
assert.equal(writes.length, 2); // Initial creation, then linking the completed opening turn.
assert.equal(writes[0].args.p_asked_turn_id, null);
assert.equal(writes[1].args.p_asked_turn_id, TURN_ID);
assert.deepEqual(writes[1].args.p_expected_answer_schema, writes[0].args.p_expected_answer_schema);
assert.ok(calls.findIndex(call => call.fn === 'set_agentic_rectification_conversation_focus')
< calls.findIndex(call => call.fn === 'create_agentic_rectification_run_attempt'));
// The public mapper drops the route's internal done marker; run.completed + EOF is its public completion contract.
assert.equal(safePublicEvent({ type: 'done', emitted: true }), null);
assert.equal(events.at(-1)?.type, 'run.completed');
console.log(JSON.stringify({ focusQuestion: focus.question_id, reloadsAfterWrite,
focusWrites: calls.filter(call => call.fn === 'set_agentic_rectification_conversation_focus').length,
eventTypes: events.map(event => event.type) }));
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 60_000,
});
assert.equal(result.status, 0, result.stderr + result.stdout);
console.log(result.stdout.trim());
});