- D2 resumable hint becomes「上次那次校正还没完成,可以在历史对话里接着做。」 so it does not imply the homepage pill resumes (it always creates). - The click-time non-self error in useRectificationSurface is the same user-visible sentence as the homepage hint; add the full stop. - Source, test, VOICE, DESIGN, CHANGELOG, BUG-1041, testing list and PROGRESS (amendment by Claude) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
199 lines
9.8 KiB
TypeScript
199 lines
9.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import {
|
|
StarterEntries,
|
|
StarterGreeting,
|
|
rectificationEntryHint,
|
|
type StarterEntriesProps,
|
|
} from "../src/components/starter-home.tsx";
|
|
import { setCurrentSubject } from "../src/lib/current-subject.ts";
|
|
import { getRectificationEntrySummary } from "../src/lib/rectification-agentic/v9/case-service.ts";
|
|
import {
|
|
entrySummaryFromResponse,
|
|
resolveRectificationEntryAction,
|
|
type RectificationEntrySummary,
|
|
} from "../src/lib/rectification-entry.ts";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const CASE_ID = "00000000-0000-4000-8000-000000000041";
|
|
const RESUMABLE_HINT = "上次那次校正还没完成,可以在历史对话里接着做。";
|
|
const NON_SELF_HINT = "生时校正暂时只支持本人。";
|
|
const REMOVED_HINTS = /不确定出生时间时|上次已经校正完|未完成的那次可以从左侧继续|看看今天的整体节奏| · /;
|
|
|
|
const summaries = {
|
|
firstRun: { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null },
|
|
rectified: {
|
|
hasResumableCase: false,
|
|
hasTerminalCaseWithTime: true,
|
|
latestResumable: null,
|
|
latestTerminal: { caseId: CASE_ID, status: "confirmed", hasUsableTime: true },
|
|
},
|
|
resumable: {
|
|
hasResumableCase: true,
|
|
hasTerminalCaseWithTime: true,
|
|
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
|
|
latestTerminal: null,
|
|
},
|
|
} satisfies Record<string, RectificationEntrySummary>;
|
|
|
|
function entriesProps(overrides: Partial<StarterEntriesProps> = {}): StarterEntriesProps {
|
|
return {
|
|
natalMinuteAvailable: true,
|
|
dailyStarlanguageQuestion: "深入看今日",
|
|
productEntrypointsDisabled: false,
|
|
startDailyStarlanguageConsultation: () => undefined,
|
|
rectificationCardLabel: "开始新的生时校正",
|
|
rectificationLoading: false,
|
|
rectificationMutationPending: false,
|
|
openRectificationFromHomepage: () => undefined,
|
|
rectificationEntrySummary: null,
|
|
rectificationError: "",
|
|
rectificationSurfaceOpen: false,
|
|
rectificationErrorMessage: "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function hintOf(markup: string): string | null {
|
|
return markup.match(/<p class="starter-entry-hint">([^<]*)<\/p>/)?.[1] ?? null;
|
|
}
|
|
|
|
test("D2: the line under the pills only appears when the user has something to do", () => {
|
|
// First run and an already-rectified account: no hint at all.
|
|
for (const summary of [null, summaries.firstRun, summaries.rectified]) {
|
|
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summary })} />);
|
|
assert.equal(hintOf(markup), null, JSON.stringify(summary));
|
|
assert.doesNotMatch(markup, /starter-entry-hint/);
|
|
assert.doesNotMatch(markup, REMOVED_HINTS);
|
|
}
|
|
|
|
// An unfinished case: one sentence, the resumable one.
|
|
const resumable = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
|
assert.equal(hintOf(resumable), RESUMABLE_HINT);
|
|
assert.doesNotMatch(resumable, REMOVED_HINTS);
|
|
});
|
|
|
|
test("D2: a non-self subject gets the self-only sentence, even with an unfinished case on record", async () => {
|
|
assert.equal(rectificationEntryHint(false, null), NON_SELF_HINT);
|
|
assert.equal(rectificationEntryHint(false, summaries.resumable), NON_SELF_HINT);
|
|
assert.equal(rectificationEntryHint(true, summaries.resumable), RESUMABLE_HINT);
|
|
assert.equal(rectificationEntryHint(true, summaries.rectified), null);
|
|
assert.equal(rectificationEntryHint(true, summaries.firstRun), null);
|
|
assert.equal(rectificationEntryHint(true, null), null);
|
|
|
|
// Rendered through the real subject store, not just the helper.
|
|
const harness = createClientLifecycleHarness();
|
|
try {
|
|
setCurrentSubject("person-fictional-1");
|
|
await harness.render(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
|
const hints = harness.elements().filter((node) => node.getAttribute("class") === "starter-entry-hint");
|
|
assert.equal(hints.length, 1);
|
|
assert.equal(hints[0].text, NON_SELF_HINT);
|
|
const rectificationPill = harness.elements().find((node) => node.getAttribute("aria-label") === "开始新的生时校正");
|
|
assert.equal(rectificationPill?.disabled, true);
|
|
} finally {
|
|
setCurrentSubject("self");
|
|
await harness.close();
|
|
}
|
|
assert.deepEqual(harness.errors, []);
|
|
});
|
|
|
|
test("D4: the no-minute boundary sentence stays under the pills", () => {
|
|
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ natalMinuteAvailable: false })} />);
|
|
assert.match(markup, /<p class="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。<\/p>/);
|
|
assert.doesNotMatch(markup, REMOVED_HINTS);
|
|
const withMinute = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
|
|
assert.doesNotMatch(withMinute, /starter-entry-boundary/);
|
|
});
|
|
|
|
test("D1: today's trend is a sub-line under the greeting, static until ready, hidden without a minute", () => {
|
|
const ready = renderToStaticMarkup(
|
|
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="虚构的今日趋势句。" dailyTrendSettled />,
|
|
);
|
|
assert.match(ready, /<h1 id="starter-heading">早上好<\/h1><p class="starter-greeting-sub">虚构的今日趋势句。<\/p>/);
|
|
assert.doesNotMatch(ready, /role="status"|aria-busy/);
|
|
|
|
const pending = renderToStaticMarkup(
|
|
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="今天的星语还没写出来。" dailyTrendSettled={false} />,
|
|
);
|
|
assert.match(pending, /<p class="starter-greeting-sub" role="status">今天的星语还没写出来。<\/p>/);
|
|
assert.doesNotMatch(pending, /aria-busy|spinner|inline-spin|正在加载/);
|
|
|
|
for (const markup of [
|
|
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend={null} />),
|
|
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} />),
|
|
]) {
|
|
assert.equal(markup, '<section class="starter-greeting-block" aria-labelledby="starter-heading"><h1 id="starter-heading">早上好</h1></section>');
|
|
}
|
|
|
|
// The trend no longer rides the pills' fine print.
|
|
const entries = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
|
assert.doesNotMatch(entries, /今天的星语还没写出来/);
|
|
});
|
|
|
|
test("D1: page.tsx feeds the sub-line from the same daily state the warm snapshot seeds", () => {
|
|
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
|
assert.match(page, /useState<DailyStarlanguageState>\(\(\) => warmStart\(\)\?\.daily \?\? \{ kind: "pending" \}\)/);
|
|
assert.match(page, /<StarterGreeting starterGreeting=\{starterGreeting\} dailyTrend=\{natalMinuteAvailable \? dailyStarlanguageTrend : null\} dailyTrendSettled=\{dailyStarlanguage\.kind === "ready"\} \/>/);
|
|
assert.doesNotMatch(page, /dailyStarlanguageTrend=\{/);
|
|
});
|
|
|
|
test("D3: daily entry uses MoonStar, credits use Coins, accessible names unchanged", () => {
|
|
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
|
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
|
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
|
|
assert.doesNotMatch(starterHome, /Sparkles/);
|
|
assert.match(page, /import \{ Coins \} from "lucide-react";/);
|
|
assert.match(page, /<Coins className="credit-icon" aria-hidden="true" \/>/);
|
|
assert.doesNotMatch(page, /Sparkles/);
|
|
assert.match(page, /aria-label=\{account \? `余额 \$\{account\.credits\} 点,会员与充值` : accountError \|\| "读取余额中"\}/);
|
|
|
|
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
|
|
assert.match(markup, /aria-label="深入看今日"/);
|
|
assert.match(markup, /lucide-moon-star/);
|
|
});
|
|
|
|
test("BUG-1041: the homepage parser reads what the entry-summary route actually returns", async () => {
|
|
// The route returns NextResponse.json(await getRectificationEntrySummary(...)), i.e. the camelCase
|
|
// projection of the snake_case RPC row. Round-trip it through JSON the way the browser receives it.
|
|
const accounting = {
|
|
rpc: async (fn: string) => {
|
|
assert.equal(fn, "get_agentic_rectification_entry_summary");
|
|
return {
|
|
data: {
|
|
has_resumable_case: false,
|
|
has_terminal_case_with_time: true,
|
|
latest_resumable: null,
|
|
latest_terminal: { case_id: CASE_ID, status: "confirmed", has_usable_time: true },
|
|
},
|
|
error: null,
|
|
};
|
|
},
|
|
} as unknown as Parameters<typeof getRectificationEntrySummary>[0];
|
|
const wire = JSON.parse(JSON.stringify(await getRectificationEntrySummary(accounting, "user-fictional")));
|
|
assert.equal("has_terminal_case_with_time" in wire, false, "the route speaks camelCase");
|
|
|
|
const summary = entrySummaryFromResponse(wire);
|
|
assert.equal(summary.hasTerminalCaseWithTime, true);
|
|
assert.equal(summary.latestTerminal?.caseId, CASE_ID);
|
|
assert.equal(summary.latestTerminal?.hasUsableTime, true);
|
|
assert.equal(resolveRectificationEntryAction(summary), "restart");
|
|
|
|
const resumableWire = JSON.parse(JSON.stringify({
|
|
hasResumableCase: true,
|
|
hasTerminalCaseWithTime: false,
|
|
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
|
|
latestTerminal: null,
|
|
}));
|
|
const resumable = entrySummaryFromResponse(resumableWire);
|
|
assert.equal(resumable.hasResumableCase, true);
|
|
assert.equal(resumable.latestResumable?.lastActivityAt, "2026-09-26T00:00:00.000Z");
|
|
assert.equal(rectificationEntryHint(true, resumable), RESUMABLE_HINT);
|
|
});
|