被放宽的抽屉、外观和设置测试改回点按检查(U10)。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-27 00:51:11 +08:00
co-authored by Grok (agent loop)
parent 3223d06d1d
commit b2a561b34c
11 changed files with 365 additions and 49 deletions
@@ -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 |
+1 -1
View File
@@ -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 |
+6 -1
View File
@@ -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";
+6
View File
@@ -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()],
});
+56 -15
View File
@@ -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<typeof h.elements>[number]): ReturnType<typeof h.elements> => [
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<AccountSettingsDialog> = \["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;原因:右侧内容区增加内边距后,资料表单仍需使用舒适的阅读宽度,避免右侧留下过大的空白。
+4 -1
View File
@@ -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; \}/);
+46 -20
View File
@@ -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");
// 原值:生命周期宿主打开原生 <dialog>,点格子和 Esc 关闭
// 新值:源码用 Sheet;格子静态渲染 23 项。选中后 setSheetOpen(false) 再 onSelect
// 原因:K3 改用 Sheet。这个测试宿主的 portal 挂不进 body,不能假装点开过浮层。
assert.match(source, /<Sheet open=\{sheetOpen\}/);
assert.match(source, /side="bottom"/);
assert.match(source, /CHART_VIEW_COPY\.chartTypeSheetTitle/);
assert.match(source, /CHART_VIEW_COPY\.chartTypeSheetClose/);
assert.match(source, /setSheetOpen\(false\);\s*onSelect\(id\);/);
assert.doesNotMatch(source, /<dialog|<button|ToggleGroup|role="tablist"/);
const markup = renderToStaticMarkup(React.createElement(ChartTypeSheetBody, { current: "D1", onSelect: () => {} }));
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, />本命</);
test("the type sheet closes when a cell is chosen or Escape is pressed", async () => {
// 原值:生命周期宿主打开原生 <dialog>,点格子和 Esc(onCancel)关闭
// 新值:打开底部 Sheet,点格子调用选择并关掉;再打开后按 Esc 关掉,且不再次选择
// 原因:K3 改用 Sheet。关闭要看挂上的浮层,不再用源码里的 setSheetOpen 代替点按
const h = createClientLifecycleHarness();
const picked: string[] = [];
type Node = ReturnType<typeof h.elements>[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 () => {
+81
View File
@@ -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<typeof h.elements>[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();
}
});
@@ -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); }
}
+80 -5
View File
@@ -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<string, unknown>) => 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<object, DomListener[]>();
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<string, unknown>, 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(<ReportExportDrawer editions={editions} onClose={() => { 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;/);
@@ -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, /<ThemePreferenceMenu \/>/);
assert.match(sidebar, /<DropdownMenuSeparator className="account-menu-separator" \/>\s*<ThemePreferenceMenu \/>/);
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<string, string>();
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<typeof h.elements>[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();
}
});