diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index 70ebac17..d8bd0d3b 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -66,3 +66,22 @@ | 检查 | 命令 | 结果 | | --- | --- | --- | | 依赖卫生 | `node node_modules/tsx/dist/cli.mjs --test tests/dependency-hygiene-20261026.test.ts` | 1 通过,0 失败,退出码 0 | + +## U10 · 被放宽的点按检查改回来 + +没有新的产品故障,所以没有新的 BUG 编号。页面上多出来的只有两处为了让测试能点到真组件:登录图标在测试运行器里也能画出来;登录请求改为每次发送时再使用当前的网络函数,正式页面仍走原来的登录。 + +| 检查 | 原来 | 现在 | +| --- | --- | --- | +| 星盘类型 | 只核对源码里有抽屉 | 点 D60 关闭,再打开后按 Esc 关闭,只选中这一次 | +| 导出抽屉 | 关闭和宽度断言被拿掉 | Esc 关一次,点遮罩再关一次,右侧最宽 520px,底部 85dvh | +| 登录 | 没有输入邮箱、发码、输码的测试 | 填邮箱、勾选同意、发码、六格填入验证码、验证后进入首页 | +| 大运折叠 | 打开标记用「不少于」 | 每一条当前大运的标题和内容各一个打开标记,数量正好是条数的两倍 | +| 外观 | 到共享菜单文件里找勾 | 菜单自己画出三选一,只有「跟随系统」带勾 | +| 账户设置 | 用源码正则核对栏目 | 打开后看见三个栏目,账单可以是当前页,表单宽度类名仍在 | + +断言三栏(大运打开次数):原值 `open.length >= currentCount`。新值 `open.length === currentCount * 2`。原因是每一条当前大运的折叠根和内容面板各写一次打开标记,数量可以数清。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 这六处加上登录动作 | `node node_modules/tsx/dist/cli.mjs --test` 盘型、导出、星盘页、账户、外观、登录、登录动作 | 62 通过,0 失败,0 取消,退出码 0 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e94e76c3..e07237a7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -40,7 +40,7 @@ | `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | -| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9 已提交) | `codex/ui-kit-onboarding-fix-20261026` | +| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6、U9、U10 已提交) | `codex/ui-kit-onboarding-fix-20261026` | | —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` | | `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index 911d9521..b39b1964 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -1,6 +1,6 @@ "use client"; -import Image from "next/image"; +import NextImage from "next/image"; import { FormEvent, useState } from "react"; import { LOGIN_CONSENT_HINT, LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent"; @@ -14,6 +14,11 @@ import { dismissNotice, notifyError, notifyInfo, notifySuccess, NOTICE_DURATION_ import { createBrowserSupabaseClient } from "@/lib/supabase/client"; import { selfHostedAuthActions } from "@/modules/identity/client"; +// Next unwraps this package to the image component. The test runner leaves the +// package object, whose component sits on `default`. Either shape renders the logo. +const imageModule = NextImage as unknown as { $$typeof?: unknown; default?: typeof NextImage }; +const Image = (imageModule.$$typeof ? NextImage : (imageModule.default ?? NextImage)) as typeof NextImage; + type AuthProvider = "supabase" | "self-hosted"; type AuthMode = "otp" | "password" | "register" | "forgot"; type AuthStep = "email" | "otp" | "two-factor" | "set-password" | "existing"; diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts index c0b587a5..75291cfc 100644 --- a/frontend/src/modules/identity/client.ts +++ b/frontend/src/modules/identity/client.ts @@ -175,7 +175,13 @@ export type SelfHostedOtpClient = SelfHostedAuthClient; export type SelfHostedOtpActions = SelfHostedAuthActions; export const createSelfHostedOtpActions = createSelfHostedAuthActions; +// better-auth stores the fetch function it is given when this module loads. +// Reading the current global fetch on each request keeps the browser on the +// same fetch, and lets a test replace it after the module has already loaded. const authClient = createAuthClient({ + fetchOptions: { + customFetchImpl: (input, init) => globalThis.fetch(input, init), + }, plugins: [emailOTPClient(), twoFactorClient()], }); diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 11c05645..15b7b2e8 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -1,9 +1,12 @@ +import "./install-test-document"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createElement, createRef } from "react"; import { renderToString } from "react-dom/server"; import test from "node:test"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + import { AccountDialogOverlay, type AccountOverlayModel, @@ -71,12 +74,27 @@ test("home does not write overlay epoch or chat actions during render", () => { assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/); }); -test("settings navigation lists three panes and billing can be current", () => { +async function mountedSettings(dialog: "profile" | "billing" | "general") { + const h = createClientLifecycleHarness(); + await h.render(createElement(AccountDialogOverlay, { + open: true, + dialog, + model: overlayModel({ title: dialog === "billing" ? "账户与点数" : "个人资料" }), + })); + const walk = (node: ReturnType[number]): ReturnType => [ + node, + ...node.childNodes.flatMap((child) => walk(child)), + ]; + const nodes = [...walk(h.container), ...[...h.document.body.childNodes].flatMap((child) => walk(child))]; + return { h, nodes }; +} + +test("settings navigation lists three panes and billing can be current", async () => { const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); // 原值: renderToString 里能看到三个分区名和 aria-current="page" - // 新值: 这些字符串写在组件源码里。组件库 Dialog 要等浏览器挂上才有 HTML - // 原因: K1 设置弹窗改用组件库。分区名、当前页和「没有星盘资料」不变 + // 新值: 组件库 Dialog 挂上之后,页面文字里有三个分区名,当前页是账户与点数 + // 原因: K1 设置弹窗改用组件库,服务端字符串是空的,所以改在测试宿主里挂上再看 // 原值: four panes, including 星盘资料. // 新值: profile, billing, general. No chart-library nav item. // 原因: D8 moves chart archives to /people and removes the settings pane. @@ -85,11 +103,20 @@ test("settings navigation lists three panes and billing can be current", () => { assert.match(overlay, /dialog: "billing"/); assert.match(overlay, /dialog: "general"/); assert.doesNotMatch(overlay, /openRedeem/); - assert.match(overlay, /label: "个人资料"/); - assert.doesNotMatch(overlay, /星盘资料/); - assert.match(overlay, /label: "账户与点数"/); - assert.match(overlay, /label: "通用设置"/); - assert.match(overlay, /aria-current=\{dialog === itemDialog \? "page" : undefined\}/); + const mounted = await mountedSettings("billing"); + try { + const text = mounted.nodes.map((node) => node.textContent).join(""); + assert.match(text, /个人资料/); + assert.doesNotMatch(text, /星盘资料/); + assert.match(text, /账户与点数/); + assert.match(text, /通用设置/); + const current = mounted.nodes.find((node) => node.getAttribute("aria-current") === "page"); + assert.ok(current); + assert.match(current.text, /账户与点数/); + assert.deepEqual(mounted.h.errors, []); + } finally { + await mounted.h.close(); + } // Existence check only, kept from BUG-554. It cannot see whether the declaration // survives to the browser, which is exactly how BUG-698 slipped through: the height // was declared but written only in dvh. The real guards are the same-size contract @@ -142,17 +169,31 @@ test("settings navigation uses two columns without a misleading chevron", () => assert.match(cssDeclarations(".settings-dialog-content", styles), /padding:\s*0\s+var\(--space-8\)\s+var\(--space-8\)/); }); -test("form panes get a reading-width cap, list panes stay full-bleed", () => { +test("form panes get a reading-width cap, list panes stay full-bleed", async () => { // T8.1 / E13: the 880px dialog leaves ~690px of content, which pulls a one-column // form apart. The cap is applied per pane, inside the content box. const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); // 原值:renderToString 的 class="settings-dialog-content settings-dialog-content--form" - // 新值:源码里 formPanes 只有 profile 和 general,内容盒子按它加 --form - // 原因:K1 的 Dialog 在这个测试里不会输出 HTML。表单变窄、点数页铺满的规则不变 - assert.match(overlay, /const formPanes: ReadonlyArray = \["profile", "general"\];/); - assert.doesNotMatch(overlay, /formPanes:[\s\S]{0,80}"billing"/); - assert.match(overlay, /className=\{`settings-dialog-content\$\{isFormPane \? " settings-dialog-content--form" : ""\}`\}/); + // 新值:挂上 Dialog 之后,资料和通用设置的内容盒子带 --form,点数页不带 + // 原因:K1 的 Dialog 在服务端不输出 HTML。表单变窄、点数页铺满的规则不变 + const contentClass = async (pane: "profile" | "billing" | "general") => { + const mounted = await mountedSettings(pane); + try { + const node = mounted.nodes.find((item) => (item.getAttribute("class") ?? "").includes("settings-dialog-content")); + assert.ok(node, pane); + return node.getAttribute("class") ?? ""; + } finally { + await mounted.h.close(); + } + }; + for (const pane of ["profile", "general"] as const) { + assert.equal( + await contentClass(pane), + "settings-dialog-content settings-dialog-content--form", + `${pane} is a form pane and must carry the cap`, + ); + } + assert.equal(await contentClass("billing"), "settings-dialog-content", "billing must stay full-bleed"); const cap = cssDeclarations(".settings-dialog-content--form > *", styles); // 原值:420–460px;新值:560px;原因:右侧内容区增加内边距后,资料表单仍需使用舒适的阅读宽度,避免右侧留下过大的空白。 diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index cb86aead..635b3e49 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -437,7 +437,10 @@ test("the dasha tab folds every track to the current period and keeps a now card const panels = markup.match(/data-slot="collapsible-content"/g) ?? []; const open = markup.match(/data-open=/g) ?? []; const currentCount = periods.filter((period) => period.current).length; - assert.ok(open.length >= currentCount); + // 原值:open.length >= currentCount + // 新值:open.length === currentCount * 2 + // 原因:每一条当前大运的折叠根和内容面板各写一次 data-open,数量可以数清,不必再用大于等于放过多出来的标记 + assert.equal(open.length, currentCount * 2); assert.ok(panels.length > currentCount); assert.match(cssDeclarations(".chart-page-dasha-summary", globalStyles), /min-height:\s*44px/); assert.match(globalStyles, /\.chart-page-dasha-period \[data-slot="collapsible-content"\]\[data-closed\] ul \{ display: none; \}/); diff --git a/frontend/tests/chart-type-picker.test.tsx b/frontend/tests/chart-type-picker.test.tsx index 371e255a..3d11c818 100644 --- a/frontend/tests/chart-type-picker.test.tsx +++ b/frontend/tests/chart-type-picker.test.tsx @@ -1,15 +1,17 @@ +import "./install-test-document"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; -import { ChartTypePicker, ChartTypeSheetBody } from "../src/components/chart-page/chart-type-picker.tsx"; +import { ChartTypePicker } from "../src/components/chart-page/chart-type-picker.tsx"; import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; import { CHART_TYPE_ORDER, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "../src/lib/chart-view-labels.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; Object.assign(globalThis, { React }); @@ -82,25 +84,49 @@ test("the strip is one sideways-scrolling line and the sheet is a three-column g assert.doesNotMatch(globalStyles, /\.chart-page-varga-chips/); }); -test("the type sheet lists every chart and choosing one is wired to close", () => { - const source = readFileSync(new URL("../src/components/chart-page/chart-type-picker.tsx", import.meta.url), "utf8"); - // 原值:生命周期宿主打开原生 ,点格子和 Esc 关闭 - // 新值:源码用 Sheet;格子静态渲染 23 项。选中后 setSheetOpen(false) 再 onSelect - // 原因:K3 改用 Sheet。这个测试宿主的 portal 挂不进 body,不能假装点开过浮层。 - assert.match(source, / {} })); - const cells = [...markup.matchAll(/class="([^"]*chart-page-type-cell[^"]*)"/g)]; - assert.equal(cells.length, 23); - assert.match(markup, /aria-pressed="true"/); - assert.match(markup, /前世业力/); - assert.match(markup, /以月亮为第 1 宫/); - assert.match(markup, /星盘类型|本命/); - assert.match(markup, />本命 { + // 原值:生命周期宿主打开原生 ,点格子和 Esc(onCancel)关闭 + // 新值:打开底部 Sheet,点格子调用选择并关掉;再打开后按 Esc 关掉,且不再次选择 + // 原因:K3 改用 Sheet。关闭要看挂上的浮层,不再用源码里的 setSheetOpen 代替点按 + const h = createClientLifecycleHarness(); + const picked: string[] = []; + type Node = ReturnType[number]; + const nodes = () => { + const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child))]; + return [...walk(h.container), ...[...h.document.body.childNodes].flatMap((child) => walk(child))]; + }; + const sheet = () => nodes().find((node) => node.getAttribute("data-slot") === "sheet-content"); + try { + await h.render(React.createElement(ChartTypePicker, { + current: "D1", + onSelect: (id: string) => picked.push(id), + })); + const menu = nodes().find((node) => node.getAttribute("aria-label") === CHART_VIEW_COPY.chartTypeMenu); + assert.ok(menu); + assert.equal(sheet(), undefined); + await h.event(menu); + assert.ok(sheet()); + assert.match(sheet()!.text, /星盘类型/); + const cells = nodes().filter((node) => (node.getAttribute("class") ?? "").includes("chart-page-type-cell")); + assert.equal(cells.length, 23); + assert.match(cells.find((cell) => cell.text.startsWith("D60"))!.text, /前世业力/); + assert.match(cells.find((cell) => cell.text.startsWith("月亮盘"))!.text, /以月亮为第 1 宫/); + assert.equal(cells.find((cell) => cell.text.startsWith("本命"))!.getAttribute("aria-pressed"), "true"); + await h.event(cells.find((cell) => cell.text.startsWith("D60"))!); + assert.deepEqual(picked, ["D60"]); + await h.idle(); + assert.equal(sheet(), undefined, "choosing a cell closes the sheet"); + await h.event(nodes().find((node) => node.getAttribute("aria-label") === CHART_VIEW_COPY.chartTypeMenu)!); + const open = sheet(); + assert.ok(open); + await h.event(open, "onKeyDown", { key: "Escape" }); + await h.idle(); + assert.equal(sheet(), undefined, "Escape closes the sheet"); + assert.deepEqual(picked, ["D60"]); + assert.deepEqual(h.errors, []); + } finally { + await h.close(); + } }); test("the varga layer asks the engine for D40, D45 and D60 and the page draws them", async () => { diff --git a/frontend/tests/login-code-entry.test.tsx b/frontend/tests/login-code-entry.test.tsx new file mode 100644 index 00000000..97102d4c --- /dev/null +++ b/frontend/tests/login-code-entry.test.tsx @@ -0,0 +1,81 @@ +import "./install-test-document"; +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; + +import { EmailOtpLogin } from "../src/components/email-otp-login.tsx"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +test("login types an email, sends a code, then types that code", async () => { + // 原值:K5 验收要的这条交互没有测试,登录页只核对源码里的文案 + // 新值:输入邮箱、勾选同意、发送验证码,再把六位码打进格子,然后验证 + // 原因:K5 把验证码换成六格输入。发码和输码仍要能点通,不能只看源码里有这些词 + const h = createClientLifecycleHarness(); + const calls: { url: string; body: string }[] = []; + const assigned: string[] = []; + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = typeof init?.body === "string" ? init.body : ""; + calls.push({ url, body }); + const payload = url.includes("/get-session") ? { session: null, user: null } : {}; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + Object.assign(window, { + location: { + href: "https://staging.jyotisha.chat/login", + assign(path: string) { assigned.push(path); }, + }, + }); + type Node = ReturnType[number]; + const nodes = () => { + const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child))]; + return walk(h.container); + }; + const button = (label: string) => nodes().find((node) => node.tagName === "BUTTON" && node.text.includes(label)); + try { + await h.render(React.createElement(EmailOtpLogin, { + provider: "self-hosted", + passwordEnabled: true, + successPath: "/", + testOtp: "654321", + })); + assert.deepEqual(h.errors, []); + const email = nodes().find((node) => node.getAttribute("id") === "login-email"); + assert.ok(email); + (email as { value: string }).value = "person@example.com"; + await h.event(email, "onChange", { target: email, currentTarget: email }); + assert.equal(typeof email.props.onChange, "function"); + const consent = nodes().find((node) => node.getAttribute("role") === "checkbox"); + assert.ok(consent); + await h.event(consent); + const form = nodes().find((node) => node.tagName === "FORM"); + assert.ok(form); + await h.event(form, "onSubmit"); + await h.idle(); + assert.ok(button("验证并登录")); + assert.ok(calls.some((call) => call.body.includes("person@example.com"))); + const slots = nodes().filter((node) => node.getAttribute("data-slot") === "input-otp-slot"); + assert.equal(slots.length, 6); + const otp = nodes().find((node) => node.tagName === "INPUT" && node.getAttribute("autocomplete") === "one-time-code") + ?? nodes().find((node) => node.tagName === "INPUT"); + assert.ok(otp); + (otp as { value?: string }).value = "654321"; + await h.event(otp, "onChange", { target: otp, currentTarget: otp }); + await h.idle(); + const filled = nodes().filter((node) => node.getAttribute("data-slot") === "input-otp-slot").map((node) => node.text).join(""); + assert.equal(filled, "654321"); + await h.event(nodes().find((node) => node.tagName === "FORM")!, "onSubmit"); + await h.idle(); + assert.deepEqual(assigned, ["/"]); + assert.deepEqual(h.errors, []); + } finally { + globalThis.fetch = previousFetch; + await h.close(); + } +}); diff --git a/frontend/tests/react-client-lifecycle-test-support.ts b/frontend/tests/react-client-lifecycle-test-support.ts index ad0af15f..fef0c94b 100644 --- a/frontend/tests/react-client-lifecycle-test-support.ts +++ b/frontend/tests/react-client-lifecycle-test-support.ts @@ -25,6 +25,16 @@ class HostElement { getAttribute(name: string) { return this.attributes.get(name) ?? null; } hasAttribute(name: string) { return this.attributes.has(name); } focus() { this.ownerDocument.activeElement = this; } + // Code fields read and write the caret. Null means "no caret yet", which is + // what an unfocused input reports, so the field can skip moving it. + selectionStart: number | null = null; + selectionEnd: number | null = null; + selectionDirection: "forward" | "backward" | "none" | null = null; + setSelectionRange(start: number, end: number, direction?: "forward" | "backward" | "none") { + this.selectionStart = start; + this.selectionEnd = end; + if (direction) this.selectionDirection = direction; + } showModal() { this.open = true; } close() { this.open = false; } scrollIntoView() {} @@ -165,6 +175,8 @@ class HostDocument { defaultView: unknown; addEventListener() {} removeEventListener() {} + // Code fields ask which node sits under a point. There is no layout, so nothing does. + elementFromPoint() { return null; } createElement(tag: string) { return new HostElement(tag.toUpperCase(), this); } createElementNS(_namespace: string, tag: string) { return this.createElement(tag); } } diff --git a/frontend/tests/report-export-drawer.test.tsx b/frontend/tests/report-export-drawer.test.tsx index 3cf70e2e..82317059 100644 --- a/frontend/tests/report-export-drawer.test.tsx +++ b/frontend/tests/report-export-drawer.test.tsx @@ -191,18 +191,88 @@ test("reader actions keep only the accessible export icon", async () => { } finally { await h.close(); } }); -test("mounted drawer closes from its control and restores focus", async () => { +type DomListener = { type: string; fn: (event: Record) => void; capture: boolean }; + +function captureFlag(options: unknown): boolean { + return options === true || (typeof options === "object" && options !== null && Boolean((options as { capture?: boolean }).capture)); +} + +function trackDocumentListeners(target: object): DomListener[] { + const bag: DomListener[] = []; + const host = target as { + addEventListener: (type: string, fn: DomListener["fn"], options?: unknown) => void; + removeEventListener: (type: string, fn: DomListener["fn"]) => void; + }; + host.addEventListener = (type, fn, options) => { bag.push({ type, fn, capture: captureFlag(options) }); }; + host.removeEventListener = (type, fn) => { + const index = bag.findIndex((entry) => entry.type === type && entry.fn === fn); + if (index >= 0) bag.splice(index, 1); + }; + return bag; +} + +function trackElementListeners(proto: { addEventListener: Function; removeEventListener: Function }) { + const bags = new WeakMap(); + const originalAdd = proto.addEventListener; + const originalRemove = proto.removeEventListener; + proto.addEventListener = function (this: object, type: string, fn: DomListener["fn"], options?: unknown) { + const bag = bags.get(this) ?? []; + if (!bags.has(this)) bags.set(this, bag); + bag.push({ type, fn, capture: captureFlag(options) }); + }; + proto.removeEventListener = function (this: object, type: string, fn: DomListener["fn"]) { + const bag = bags.get(this); + if (!bag) return; + const index = bag.findIndex((entry) => entry.type === type && entry.fn === fn); + if (index >= 0) bag.splice(index, 1); + }; + return { + bag(target: object) { return bags.get(target) ?? []; }, + restore() { + proto.addEventListener = originalAdd; + proto.removeEventListener = originalRemove; + }, + }; +} + +function fireListeners(bag: readonly DomListener[], type: string, event: Record, capture: boolean) { + for (const entry of bag.filter((item) => item.type === type && item.capture === capture)) entry.fn(event); +} + +test("mounted drawer closes from Escape and the overlay, and restores focus", async () => { // 原值:原生 dialog 的 onCancel(Esc)和点遮罩(target === currentTarget)各关一次;内部点「整篇报告」不关闭 - // 新值:点「整篇报告」不关闭;点「关闭导出」关闭;卸载后焦点回到打开前的按钮。Esc 和点遮罩由 Sheet 调 onOpenChange(false) - // 原因:K5 导出改用 Sheet,不再使用原生 dialog + // 新值:点「整篇报告」不关闭;按 Esc 关一次;点遮罩再关一次;卸载后焦点回到打开前的按钮 + // 原因:K5 导出改用 Sheet。Esc 走浮层自己的按键处理,点遮罩走文档上的捕获监听,不再用源码正则代替 const h = createClientLifecycleHarness(); let closes = 0; const previous = document.createElement("button"); previous.focus(); + const documentListeners = trackDocumentListeners(h.document); + const elementListeners = trackElementListeners(Object.getPrototypeOf(h.document.body) as { addEventListener: Function; removeEventListener: Function }); try { await h.render( { closes += 1; }} />); await h.event(tab(h, "整篇报告")); assert.equal(closes, 0); - await h.event(allNodes(h).find(node => node.getAttribute("aria-label") === "关闭导出")!); assert.equal(closes, 1); + await h.idle(); + const sheet = allNodes(h).find(node => node.getAttribute("data-slot") === "sheet-content"); + assert.ok(sheet); + await h.event(sheet, "onKeyDown", { key: "Escape" }); + assert.equal(closes, 1); + const overlay = allNodes(h).find(node => node.getAttribute("data-slot") === "sheet-overlay"); + assert.ok(overlay); + const click = { + type: "click", + target: overlay, + currentTarget: h.document, + button: 0, + composedPath: () => [overlay], + preventDefault() {}, + stopPropagation() {}, + }; + await h.update(() => { + fireListeners(documentListeners, "click", click, true); + fireListeners(elementListeners.bag(overlay), "click", click, false); + }); + assert.equal(closes, 2); await h.render(null); assert.equal(document.activeElement, previous); - } finally { await h.close(); } + } finally { elementListeners.restore(); await h.close(); } }); test("drawer contract uses a sheet from the right or the bottom, and the list still scrolls", () => { @@ -216,8 +286,13 @@ test("drawer contract uses a sheet from the right or the bottom, and the list st const css = styles.slice(styles.indexOf(".report-export-drawer {"), styles.indexOf("personal-report: unique block")); assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/); assert.match(css, /min-height: 44px/); + // 原值:窄屏在 @media 之前用 bottom inset、85vh 和 85dvh;桌面 @media (min-width: 860px) 居中,宽 min(520px, 92vw),高 80vh + // 新值:窄屏是 data-side=bottom,高 85dvh,上圆角;桌面是 data-side=right,宽 min(520px, 92vw),最宽 520px + // 原因:K5 用 Sheet 的 side 区分桌面和手机,不再用原生 dialog 的媒体查询。vh 和 dvh 不再写在同一条规则里 assert.match(css, /\.report-export-drawer\[data-side="right"\] \{[^}]*width: min\(520px, 92vw\);/); + assert.match(css, /\.report-export-drawer\[data-side="right"\] \{[^}]*max-width: 520px;/); assert.match(css, /\.report-export-drawer\[data-side="bottom"\] \{[^}]*max-height: 85dvh;/); + assert.match(css, /\.report-export-drawer\[data-side="bottom"\] \{[^}]*border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0;/); assert.match(css, /\.report-export-body \{[^}]*min-height: 0;[^}]*overflow-y: auto;/); assert.match(css, /\.report-export-header \{[^}]*flex-shrink: 0;/); assert.match(css, /\.report-export-footer \{[^}]*flex-shrink: 0;/); diff --git a/frontend/tests/theme-preference-contract.test.ts b/frontend/tests/theme-preference-contract.test.ts index 525c426b..ebc9d5c1 100644 --- a/frontend/tests/theme-preference-contract.test.ts +++ b/frontend/tests/theme-preference-contract.test.ts @@ -1,13 +1,21 @@ +import "./install-test-document"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import React from "react"; import test from "node:test"; +import { ThemePreferenceMenu } from "../src/components/theme-preference-menu.tsx"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../src/components/ui/dropdown-menu.tsx"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + import { THEME_STORAGE_KEY, isThemePreference, themePreferenceBootScript, } from "../src/lib/theme-preference.ts"; +Object.assign(globalThis, { React }); + const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); const menu = readFileSync(new URL("../src/components/theme-preference-menu.tsx", import.meta.url), "utf8"); const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); @@ -63,19 +71,59 @@ test("stored preference is read as external state, not effect-synced React state assert.match(lib, /for \(const listener of listeners\) listener\(\);/); }); -test("the control lives in the account menu and keeps menu semantics", () => { - const radioItem = readFileSync(new URL("../src/components/ui/dropdown-menu.tsx", import.meta.url), "utf8"); - // 原值:Menu.Separator、Menu.RadioGroup、Menu.RadioItem、Menu.RadioItemIndicator,勾选样式是 .theme-menu-check。 - // 新值:DropdownMenuSeparator、DropdownMenuRadioGroup、DropdownMenuRadioItem;勾选标记在组件库里,颜色仍是主题色。 - // 原因:K1 账户菜单改用组件库。三项外观、点了不立刻关上,都还在。 +test("the control lives in the account menu and keeps menu semantics", async () => { + // 原值:勾选断言读共享下拉菜单源码里的 RadioItemIndicator,样式类是 .theme-menu-check。 + // 新值:打开外观菜单后,只有当前选中的「跟随系统」渲染出勾,浅色和深色没有勾。 + // 原因:K1 改用组件库。勾要出现在外观菜单自己的输出里,不能靠共享文件里有这个零件来证明。 assert.match(sidebar, //); assert.match(sidebar, /\s*/); assert.match(menu, /DropdownMenuRadioGroup/); assert.match(menu, /DropdownMenuRadioItem/); - assert.match(radioItem, /RadioItemIndicator/); // Picking a theme should not slam the menu shut before the change is visible. assert.match(menu, /closeOnClick=\{false\}/); assert.match(menu, /外观/); for (const label of ["浅色", "深色", "跟随系统"]) assert.match(menu, new RegExp(label)); assert.match(globalStyles, /\.theme-menu \[data-slot="dropdown-menu-radio-item-indicator"\] \{[^}]*color: var\(--color-action\)/); + + const h = createClientLifecycleHarness(); + const memory = new Map(); + Object.assign(globalThis, { + localStorage: { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { memory.set(key, value); }, + removeItem: (key: string) => { memory.delete(key); }, + }, + }); + type Node = ReturnType[number]; + const nodes = () => { + const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child))]; + return [...walk(h.container), ...[...h.document.body.childNodes].flatMap((child) => walk(child))]; + }; + const radios = () => nodes().filter((node) => node.getAttribute("role") === "menuitemradio"); + try { + await h.render(React.createElement(DropdownMenu, { open: true }, + React.createElement(DropdownMenuTrigger, null, "账户"), + React.createElement(DropdownMenuContent, null, React.createElement(ThemePreferenceMenu)), + )); + for (let attempt = 0; attempt < 30 && radios().length < 3; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); + await h.idle(); + } + const byLabel = (label: string) => radios().find((node) => node.text.includes(label)); + const checks = (node: Node | undefined) => { + const walk = (current: Node): Node[] => [current, ...current.childNodes.flatMap((child) => walk(child))]; + const slot = node ? walk(node).find((child) => child.getAttribute("data-slot") === "dropdown-menu-radio-item-indicator") : undefined; + return slot ? walk(slot).filter((child) => child.tagName === "SVG").length : 0; + }; + assert.equal(byLabel("跟随系统")?.getAttribute("aria-checked"), "true"); + assert.equal(checks(byLabel("跟随系统")), 1); + assert.equal(byLabel("浅色")?.getAttribute("aria-checked"), "false"); + assert.equal(checks(byLabel("浅色")), 0); + assert.equal(byLabel("深色")?.getAttribute("aria-checked"), "false"); + assert.equal(checks(byLabel("深色")), 0); + assert.deepEqual(h.errors, []); + } finally { + Reflect.deleteProperty(globalThis, "localStorage"); + await h.close(); + } });