Files
Jyotisha/frontend/tests/starter-questions.test.ts
T
jesse-uxandGrok (agent loop) 352d45b2ef
Independent Staging Quality Gate / validate (push) Failing after 9m12s
Independent Staging Quality Gate / publish (push) Skipped
外壳换成组件库:侧栏可拖宽,会话可排序(#105)。
桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。

自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。

Co-Authored-By: Grok (agent loop)
2026-10-25 01:41:19 +08:00

165 lines
9.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as pageSource } from "./home-surface.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("completed account initialization switches directly to the home cards", () => {
assert.doesNotMatch(pageSource, /setOnboardingJustCompleted\(true\)/);
assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/);
});
test("profiles without a usable birth minute still start daily fortune from the homepage card", () => {
// 原值: 无可用分钟时主题卡换 generalGuidedJyotishTopics,边界句写在「从一个主题开始」小标题。
// 新值: 主题卡下线;无分钟账号只剩问候语、今日运势卡、生时校正卡。
// 原因: 2026-09-15 产品决定删除低价值入口,不做替代。
assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/);
assert.match(pageSource, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.doesNotMatch(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
assert.doesNotMatch(pageSource, /starter-themes-heading|starter-theme-accordion|starterSuggestions/);
});
test("homepage daily card starts a consultation instead of filling the composer", () => {
// 原值: 同时锁今日星语卡与主题卡 `guided_topic` 点击。
// 新值: 只覆盖今日星语卡。
// 原因: 主题卡入口已下线,`guided_topic` 不再有发出点。
const starterSend = sourceBetween(
pageSource,
"async function startSuggestedConsultation(",
"function startDailyStarlanguageConsultation",
);
assert.match(pageSource, /onClick=\{startDailyStarlanguageConsultation\}/);
assert.doesNotMatch(pageSource, /"guided_topic"/);
assert.doesNotMatch(pageSource, /draftDailyStarlanguageQuestion/);
assert.doesNotMatch(pageSource, /onClick=\{\(\) => chooseSuggestedQuestion\(item\.text, item\.theme\)\}/);
assert.match(starterSend, /await startNewChat\(\)/);
assert.match(starterSend, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
assert.doesNotMatch(starterSend, /setDraft\(/);
assert.doesNotMatch(starterSend, /composerInput\.current\?\.focus/);
});
test("keeps session history clickable while another session is answering", () => {
// Given: the page-owned selection callback and the app sidebar session action.
const selectSession = pageSource.match(/function selectSession\(sessionId: string\) \{([\s\S]*?)\n \}/);
// When: request-time navigation constraints are inspected.
// Then: pending request state cannot disable read-only session switching.
assert.ok(selectSession);
assert.doesNotMatch(selectSession[1], /pendingSessionId|isLoading|cancellationPending|creatingSession/);
assert.match(appSidebarSource, /onSelectSession\(session\.id\)/);
assert.doesNotMatch(appSidebarSource, /pendingSession|isLoading|cancellationPending|requestPending/);
});
test("sizes the model popup to its content with responsive bounds", () => {
// Given: the model selector popup styles.
const popupStyles = sourceBetween(globalStyles, ".model-selector-popup {", "}\n.model-selector-popup[data-starting-style]");
// When: its responsive width is inspected.
// Then: short labels stay compact while long content remains viewport-safe.
assert.match(popupStyles, /width:\s*max-content/);
assert.match(popupStyles, /min-width:\s*180px/);
assert.match(popupStyles, /max-width:\s*min\(420px,/);
});
test("centers the credit value with its icon", () => {
// Given: the credit value styles next to the existing flex-centered icon.
// 原值:`.credit-button span` 到 `.credit-button .credit-icon`
// 新值:`.credit-balance .credit-roll-value`
// 原因:数字改成上下滚动,行盒仍是 inline-flex、居中、line-height 1
const creditValueStyles = sourceBetween(globalStyles, ".credit-balance .credit-roll-value {", "}");
// When: the value line box is inspected.
// Then: it participates in flex centering without extra line-height drift.
assert.match(creditValueStyles, /display:\s*inline-flex/);
assert.match(creditValueStyles, /align-items:\s*center/);
assert.match(creditValueStyles, /line-height:\s*1/);
});
test("routes account actions through a menu and focused dialogs", () => {
// Given: the account surface state and entry-point handlers.
// When: the page source is inspected for independent menu and dialog routes.
// Then: profile and logout stay focused dialogs; billing opens in the settings pane.
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog \| null>\(null\)/);
assert.match(pageSource, /openAccountDialog\("profile"/);
assert.match(pageSource, /openAccountDialog\("logout"/);
assert.doesNotMatch(pageSource, /type AccountDialog = "profile" \| "redeem" \| "logout"/);
assert.doesNotMatch(pageSource, /openAccountDialog\("redeem"/);
// 原值:`<Menu.Popup className="account-menu-popup"` 与 `Menu.Root`
// 新值:`DropdownMenuContent` 类名 `account-menu-popup w-auto min-w-56`,根是 `DropdownMenu`
// 原因:K1 账户菜单改用组件库,弹出层仍按内容宽度,不跟触发器等宽
assert.match(appSidebarSource, /<DropdownMenuContent\n\s*className="account-menu-popup w-auto min-w-56"/);
assert.doesNotMatch(appSidebarSource, /<Menu\.Popup/);
assert.match(appSidebarSource, /<DropdownMenu open=\{accountMenuOpen\} onOpenChange=\{onAccountMenuOpenChange\} modal=\{false\}>/);
});
test("membership entries navigate without auto-opening redeem", () => {
// Given: the account menu, credit control and insufficient-balance paths.
// 原值: membershipHref 跳 /membership,不打开 redeem 弹窗
// 新值: 三处都打开 billing 分区,仍不打开独立兑换弹窗
// 原因: 兑换码是 billing 页签,不是独立 dialog
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
assert.doesNotMatch(pageSource, /activeAccountDialog === "redeem"/);
assert.doesNotMatch(pageSource, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/);
assert.doesNotMatch(pageSource, /redeemCode|redeeming|redeemError|redeemMessage/);
});
test("removes the monolithic account sheet", () => {
// Given: the former sheet implementation names.
// When: the page and global styles are inspected.
// Then: no right-side account sheet remains.
assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/);
assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/);
});
test("removes the user-facing admin button from the chat page", () => {
// Given: the administrator-only route and the new account menu.
// Then: code management is no longer surfaced to users on the chat page.
assert.doesNotMatch(appSidebarSource, /adminUrl|account\.isAdmin|后台管理/);
assert.doesNotMatch(pageSource, /account\.isAdmin && account\.adminUrl/);
assert.doesNotMatch(pageSource, /className="admin-button"/);
assert.doesNotMatch(pageSource, /ShieldCheck/);
});
test("keeps the empty starter home at the top instead of auto-scrolling", () => {
// Former lock: a page-level effect starting `if (starterHomeVisible) return;` that called
// `container.scrollTo`. The follow now lives in useConversationScrollAnchor (BUG-478); the page
// switches it off for the starter home through the hook's `active` argument instead.
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,/);
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
const followEffect = anchorSource.slice(anchorSource.indexOf("// Follow: while anchored"));
assert.match(followEffect, /if \(!active \|\| !element\) return;/);
assert.ok(followEffect.indexOf("if (!active || !element) return;") < followEffect.indexOf("element.scrollTop = element.scrollHeight"));
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
assert.match(pageSource, /ref=\{conversation\} className=\{`conversation/);
assert.doesNotMatch(pageSource, /conversationEnd|scrollIntoView/);
});
test("does not submit the composer while an IME composition is active", () => {
const keyHandler = sourceBetween(
pageSource,
"function handleComposerKeyDown",
"\n\n if (!hydrated",
);
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
});
test("announces conversation errors to assistive technology", () => {
assert.match(pageSource, /<p className="error-message" role="alert">\{activeError\}<\/p>/);
});