对话页换成组件库,桌面旁边可以打开本命盘(#106)。
输入框里左边选模型、右边发送。回答下的赞、踩、复制、重新生成和追问改用组件库按钮。不满意原因和兑换窗改用对话框,句子不变。桌面顶栏可以打开当前人物的本命盘,能拖宽,刷新后还在;手机没有这个入口。当前会话和流式状态从 Home() 移进已有会话 store,useState 从 33 减到 25。流式、停止、排队、撤回、退点和寒暄免费没有改。 自测:tsc 退出码 0;lint 0 error、122 条警告;定向测试 275 通过、失败 0、取消 0;npm run build 退出码 0,/ 仍是 Static。首屏 gzip-9 639127,相对基线 634528 多 0.72%。隐私标记测试退出码 0。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
e4e240bf86
commit
d25dfdbe1a
@@ -66,7 +66,11 @@ test("the account dialog and onboarding paywall portal to document.body, outside
|
||||
assert.match(overlay, /<AlertDialog open/);
|
||||
assert.match(read("../src/components/ui/alert-dialog.tsx"), /AlertDialogPrimitive\.Portal/);
|
||||
assert.match(read("../src/components/ui/dialog.tsx"), /DialogPrimitive\.Portal/);
|
||||
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
|
||||
// 原值:兑换窗 `createPortal(overlay, document.body)`
|
||||
// 新值:兑换窗用 Dialog,传送由 DialogPrimitive.Portal 完成
|
||||
// 原因:K2 兑换窗改用组件库 Dialog,仍挂到 body,不留在会被 inert 的页面里
|
||||
assert.match(paywall, /<Dialog open/);
|
||||
assert.doesNotMatch(paywall, /createPortal\(/);
|
||||
|
||||
// 原值:renderToString 得到 role="dialog",且没有 inert
|
||||
// 新值:注销走 AlertDialog,组件库根把角色写成 alert-dialog;页面源码不写 inert
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CHAT_CHART_OPEN_STORAGE_KEY,
|
||||
CHAT_CHART_WIDTH_DEFAULT,
|
||||
CHAT_CHART_WIDTH_MAX,
|
||||
CHAT_CHART_WIDTH_MIN,
|
||||
CHAT_CHART_WIDTH_STORAGE_KEY,
|
||||
clampChatChartWidth,
|
||||
readStoredChatChartOpen,
|
||||
readStoredChatChartWidth,
|
||||
writeStoredChatChartOpen,
|
||||
writeStoredChatChartWidth,
|
||||
type ChatChartStorage,
|
||||
} from "../src/lib/chat-chart-panel-state.ts";
|
||||
|
||||
function memoryStorage(seed: Record<string, string> = {}): ChatChartStorage & { values: Map<string, string> } {
|
||||
const values = new Map(Object.entries(seed));
|
||||
return {
|
||||
values,
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => { values.set(key, String(value)); },
|
||||
};
|
||||
}
|
||||
|
||||
test("chat natal panel remembers open and width on this machine", () => {
|
||||
const storage = memoryStorage();
|
||||
assert.equal(readStoredChatChartOpen(storage), false);
|
||||
assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT);
|
||||
|
||||
writeStoredChatChartOpen(true, storage);
|
||||
writeStoredChatChartWidth(480, storage);
|
||||
assert.equal(storage.values.get(CHAT_CHART_OPEN_STORAGE_KEY), "1");
|
||||
assert.equal(readStoredChatChartOpen(storage), true);
|
||||
assert.equal(readStoredChatChartWidth(storage), 480);
|
||||
|
||||
writeStoredChatChartOpen(false, storage);
|
||||
assert.equal(readStoredChatChartOpen(storage), false);
|
||||
assert.equal(readStoredChatChartWidth(storage), 480);
|
||||
});
|
||||
|
||||
test("chat natal panel width stays between 280 and 560", () => {
|
||||
assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN);
|
||||
assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX);
|
||||
assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT);
|
||||
const storage = memoryStorage();
|
||||
writeStoredChatChartWidth(12, storage);
|
||||
assert.equal(storage.values.get(CHAT_CHART_WIDTH_STORAGE_KEY), String(CHAT_CHART_WIDTH_MIN));
|
||||
assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT);
|
||||
});
|
||||
|
||||
test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => {
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
|
||||
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /<ChatChartToggle \/>/);
|
||||
assert.match(page, /<ChatChartFrame>/);
|
||||
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
|
||||
assert.match(frame, /打开本命盘/);
|
||||
assert.match(frame, /收起本命盘/);
|
||||
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
|
||||
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
|
||||
assert.match(split, /ResizablePanelGroup/);
|
||||
assert.match(split, /collapsible/);
|
||||
assert.doesNotMatch(split, /data-onboarding-draw/);
|
||||
});
|
||||
@@ -285,13 +285,16 @@ test("the jump-to-latest control stays keyboard reachable and labelled", () => {
|
||||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
|
||||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||||
const buttonTag = sourceBetween(jumpSource, "<button", ">");
|
||||
// 原值:`<button`
|
||||
// 新值:`<Button`,仍是 type="button"
|
||||
// 原因:K2「跳到最新」改用组件库 Button,类名、焦点环和 44px 目标不变
|
||||
const buttonTag = sourceBetween(jumpSource, "<Button", ">");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||||
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
||||
|
||||
// Given: a real button in document order, never removed from the tab sequence.
|
||||
assert.match(jumpSource, /<button\b/);
|
||||
assert.match(jumpSource, /<Button\b/);
|
||||
assert.match(buttonTag, /type="button"/);
|
||||
assert.doesNotMatch(buttonTag, /tabIndex/);
|
||||
assert.doesNotMatch(buttonTag, /aria-hidden/);
|
||||
|
||||
@@ -64,7 +64,10 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
|
||||
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
|
||||
|
||||
// Then: only the composer subtree reads the value that changes on every keystroke.
|
||||
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
|
||||
// 原值:`<Textarea` 直接放在表单里
|
||||
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
|
||||
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
|
||||
assert.match(composerSource, /<InputGroupTextarea[\s\S]*?value=\{draft\}/);
|
||||
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
|
||||
});
|
||||
|
||||
@@ -78,7 +81,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
|
||||
// When: the page still focuses the same textarea element through its own ref.
|
||||
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
||||
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
||||
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
||||
// 原值:`<Textarea ref={inputRef}`
|
||||
// 新值:`<InputGroupTextarea ref={inputRef}`
|
||||
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
|
||||
assert.match(composerSource, /<InputGroupTextarea\n\s*ref=\{inputRef\}/);
|
||||
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
|
||||
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
|
||||
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
|
||||
|
||||
@@ -64,10 +64,13 @@ test("the redeem paywall opens on the first send refused for credits, not when t
|
||||
});
|
||||
|
||||
test("the onboarding paywall reuses the shared account dialog header", () => {
|
||||
// 原值:自绘遮罩 `account-modal-overlay`
|
||||
// 新值:`DialogContent` 使用 `account-modal paywall-modal`,标题仍在 `account-modal-header`
|
||||
// 原因:K2 兑换窗改用组件库 Dialog,文案和这些类名留下
|
||||
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
|
||||
assert.match(onboardingPaywallSource, /<\/header>/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /className="account-modal-overlay"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
|
||||
assert.match(onboardingPaywallSource, /className="dialog-close"/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ class HostElement {
|
||||
readonly nodeType = 1;
|
||||
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
|
||||
readonly childNodes: HostElement[] = [];
|
||||
readonly style = {};
|
||||
readonly style = {
|
||||
setProperty() {},
|
||||
getPropertyValue() { return ""; },
|
||||
removeProperty() { return ""; },
|
||||
};
|
||||
open = false;
|
||||
get disabled() { return this.hasAttribute("disabled"); }
|
||||
isConnected = true;
|
||||
@@ -22,6 +26,8 @@ class HostElement {
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
clientWidth = 1440;
|
||||
offsetWidth = 1440;
|
||||
readonly scrollCalls: { top?: number }[] = [];
|
||||
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
|
||||
/** No layout here: every box is the origin. Components that position from rects still run. */
|
||||
@@ -33,10 +39,17 @@ class HostElement {
|
||||
if (wanted.every(name => classes.includes(name))) return this;
|
||||
return this.parentNode?.closest(selector) ?? null;
|
||||
}
|
||||
get parentElement() { return this.parentNode; }
|
||||
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
|
||||
matches() { return false; }
|
||||
querySelector() { return null; }
|
||||
querySelectorAll() { return []; }
|
||||
getRootNode() { return this.ownerDocument; }
|
||||
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
|
||||
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
|
||||
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
||||
readonly attributes = new Map<string, string>();
|
||||
readonly dataset: Record<string, string> = {};
|
||||
parentNode: HostElement | null = null;
|
||||
textContent = "";
|
||||
get nodeValue() { return this.textContent; }
|
||||
@@ -68,9 +81,12 @@ class HostElement {
|
||||
class HostDocument {
|
||||
readonly nodeType = 9;
|
||||
readonly body: HostElement;
|
||||
readonly documentElement: HostElement;
|
||||
activeElement: HostElement | null = null;
|
||||
constructor() {
|
||||
this.documentElement = new HostElement("HTML", this);
|
||||
this.body = new HostElement("BODY", this);
|
||||
this.documentElement.appendChild(this.body);
|
||||
}
|
||||
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
|
||||
getElementById() { return null; }
|
||||
@@ -85,6 +101,12 @@ export function createClientLifecycleHarness() {
|
||||
const document = new HostDocument();
|
||||
const frames = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
let frameId = 0;
|
||||
const requestAnimationFrame = (callback: () => void) => {
|
||||
const id = ++frameId;
|
||||
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
|
||||
const window = {
|
||||
document,
|
||||
innerWidth: 1440,
|
||||
@@ -92,21 +114,19 @@ export function createClientLifecycleHarness() {
|
||||
clearTimeout,
|
||||
getComputedStyle: () => ({ direction: "ltr" }),
|
||||
HTMLElement: HostElement,
|
||||
Element: HostElement,
|
||||
Node: HostElement,
|
||||
HTMLIFrameElement: class {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
requestAnimationFrame(callback: () => void) {
|
||||
const id = ++frameId;
|
||||
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); },
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
};
|
||||
document.defaultView = window;
|
||||
// tsx's ESM parser binds its runtime to global `self` when that binding
|
||||
// exists. A partial window makes `cn` fail to parse. Next still calls
|
||||
// `self.setTimeout`, so `self` is the real global, which already has it.
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
|
||||
key, Object.getOwnPropertyDescriptor(globalThis, key),
|
||||
]));
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ReplyDownReason } from "../src/components/reply-down-reason";
|
||||
import { PRIVACY } from "../src/lib/legal-documents";
|
||||
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
|
||||
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
|
||||
@@ -141,7 +140,13 @@ test("the privacy draft says what a 👎 keeps, why, for how long, and that 注
|
||||
});
|
||||
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
|
||||
// 原值:文件顶部静态引入 ReplyDownReason,只在挂载容器里找文案
|
||||
// 新值:先建好测试文档再动态引入,并同时查找 document.body
|
||||
// 原因:组件库 Dialog 在模块加载时才决定把内容挂到 body;测试文档必须先就位
|
||||
const loadReason = () => import("../src/components/reply-down-reason");
|
||||
const visibleNodes = (h: Harness) => [...h.elements(), ...h.elements(h.document.body)];
|
||||
const visibleText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
|
||||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => visibleNodes(h).find(predicate);
|
||||
|
||||
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
||||
@@ -158,10 +163,11 @@ test("不满意原因 is optional: submitting with nothing picked still thanks t
|
||||
try {
|
||||
const key = `${SESSION}:message-5`;
|
||||
void persistReplyRating(key, "down", "第三答:注意作息。");
|
||||
const { ReplyDownReason } = await loadReason();
|
||||
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
|
||||
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
|
||||
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
|
||||
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
|
||||
assert.ok(visibleText(h).includes("哪里不满意?可以不选"));
|
||||
assert.ok(visibleText(h).includes("会把这一轮对话发给我们排查"));
|
||||
assert.deepEqual(visibleNodes(h).filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
|
||||
await h.idle();
|
||||
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
|
||||
@@ -188,6 +194,7 @@ test("picked reasons and the note are sent; an unsaved conversation sends nothin
|
||||
}) as typeof fetch;
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
const { ReplyDownReason } = await loadReason();
|
||||
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
|
||||
|
||||
@@ -13,8 +13,11 @@ function starterEntryBlock(): string {
|
||||
return css.slice(start, end);
|
||||
}
|
||||
|
||||
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
|
||||
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
|
||||
test("starter entries stay a 36px pill with a 44px hit area", () => {
|
||||
// 原值:`<button className="starter-entry"`
|
||||
// 新值:`<Button className="starter-entry"`
|
||||
// 原因:K2 今日星语改用组件库 Button,36px 胶囊和 44px 点按区仍由 `.starter-entry` 决定
|
||||
assert.match(starterHome, /<Button[\s\S]*className="starter-entry"/);
|
||||
// 原值: 2 新值: 1(生时校正 pill 删除) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
|
||||
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 1);
|
||||
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);
|
||||
|
||||
Reference in New Issue
Block a user