feat(sidebar): the avatar opens the account menu on every page (S2)
Off / the footer was a plain link home. It is now the same Menu.Root everywhere; off / its dialog entries link to /?account=<dialog>, which Home opens once its account is ready and drops from the URL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8415fdca72
commit
c15def0c50
@@ -0,0 +1,128 @@
|
||||
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 { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
import {
|
||||
ACCOUNT_DIALOG_INTENTS,
|
||||
accountDialogHref,
|
||||
parseAccountDialogIntent,
|
||||
searchWithoutAccountDialogIntent,
|
||||
} from "../src/lib/account-dialog-intent.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
/*
|
||||
* TASK-self-edit-avatar-menu-20260928 S2: the avatar opens the same account
|
||||
* menu on every page. Off `/` its dialog entries go to `/?account=<dialog>`,
|
||||
* where Home opens that dialog once and drops the parameter.
|
||||
*/
|
||||
|
||||
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("the account intent: four dialogs, anything else is ignored", () => {
|
||||
assert.deepEqual([...ACCOUNT_DIALOG_INTENTS], ["profile", "general", "billing", "logout"]);
|
||||
for (const dialog of ACCOUNT_DIALOG_INTENTS) {
|
||||
assert.equal(accountDialogHref(dialog), `/?account=${dialog}`);
|
||||
assert.equal(parseAccountDialogIntent(accountDialogHref(dialog).slice(1)), dialog);
|
||||
}
|
||||
assert.equal(parseAccountDialogIntent(""), null);
|
||||
assert.equal(parseAccountDialogIntent("?account=chart-library"), null);
|
||||
assert.equal(parseAccountDialogIntent("?account=admin"), null);
|
||||
assert.equal(parseAccountDialogIntent("?new=1"), null);
|
||||
assert.equal(searchWithoutAccountDialogIntent("?account=billing&new=1"), "?new=1");
|
||||
assert.equal(searchWithoutAccountDialogIntent("?account=billing"), "");
|
||||
});
|
||||
|
||||
test("the read-only sidebar renders the account menu's button, not a link home", async () => {
|
||||
const { SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
|
||||
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
|
||||
const markup = renderToStaticMarkup(
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
sessions={[]}
|
||||
activeSessionId={null}
|
||||
account={{ name: "示例甲", email: "fictional@example.invalid", credits: 12, initial: "示", avatar: null }}
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
assert.match(markup, /<button[^>]*class="profile-trigger"[^>]*>/);
|
||||
assert.doesNotMatch(markup, /<a[^>]*class="profile-trigger"[^>]*href="\/"/);
|
||||
assert.match(markup, /示例甲/);
|
||||
});
|
||||
|
||||
test("the four dialog entries open in place on `/` and link to `/?account=` elsewhere; theme stays in place", () => {
|
||||
assert.match(sidebar, /accountEntry\("profile", onOpenProfile,/);
|
||||
assert.match(sidebar, /accountEntry\("general", onOpenGeneral,/);
|
||||
assert.match(sidebar, /accountEntry\("billing", onOpenBilling,/);
|
||||
assert.match(sidebar, /accountEntry\("logout", onOpenLogout,/);
|
||||
const entry = sidebar.slice(sidebar.indexOf("function accountEntry("), sidebar.indexOf(" return (\n <Sidebar"));
|
||||
assert.match(entry, /return onOpen \? \(\s*<Menu\.Item className=\{className\} onClick=\{onOpen\}/);
|
||||
assert.match(entry, /<Menu\.LinkItem[\s\S]*closeOnClick[\s\S]*render=\{<AppLink href=\{accountDialogHref\(dialog\)\} onClick=\{leaveChat\} \/>\}/);
|
||||
// Theme is not a dialog: the same ThemePreferenceMenu on every page.
|
||||
assert.equal(sidebar.match(/<ThemePreferenceMenu \/>/g)?.length, 1);
|
||||
// Off `/` the menu keeps its own open state; on `/` Home controls it.
|
||||
assert.match(sidebar, /const accountMenuOpen = controls \? controlledMenuOpen : ownMenuOpen;/);
|
||||
// While the account is still being read: the same block, no link, no spinner.
|
||||
assert.match(sidebar, /<div className="profile-trigger" aria-hidden="true">/);
|
||||
});
|
||||
|
||||
test("Home opens the asked dialog once its account is ready, then drops the parameter", async () => {
|
||||
const h = createClientLifecycleHarness();
|
||||
const replaced: string[] = [];
|
||||
const location = { pathname: "/", search: "?account=billing&new=1", hash: "" };
|
||||
Object.assign(globalThis.window as object, {
|
||||
location,
|
||||
history: {
|
||||
replaceState(_state: unknown, _title: string, url: string) {
|
||||
replaced.push(url);
|
||||
const next = new URL(url, "http://localhost");
|
||||
location.search = next.search;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { AccountDialogDeepLink } = await import("../src/components/people-home-links.tsx");
|
||||
const opened: Array<[string, unknown]> = [];
|
||||
const open = (dialog: string, options?: unknown) => { opened.push([dialog, options]); };
|
||||
await h.render(<AccountDialogDeepLink ready={false} open={open} />);
|
||||
// Read and removed on mount, before the account is ready.
|
||||
assert.deepEqual(replaced, ["/?new=1"]);
|
||||
assert.equal(opened.length, 0);
|
||||
await h.render(<AccountDialogDeepLink ready open={open} />);
|
||||
assert.deepEqual(opened, [["billing", { source: "account-menu" }]]);
|
||||
// Later renders never reopen it.
|
||||
const again = () => { opened.push(["again", undefined]); };
|
||||
await h.render(<AccountDialogDeepLink ready open={again} />);
|
||||
assert.equal(opened.length, 1);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unknown value is dropped from the URL and opens nothing", async () => {
|
||||
const h = createClientLifecycleHarness();
|
||||
const replaced: string[] = [];
|
||||
const location = { pathname: "/", search: "?account=admin", hash: "" };
|
||||
Object.assign(globalThis.window as object, {
|
||||
location,
|
||||
history: { replaceState(_state: unknown, _title: string, url: string) { replaced.push(url); } },
|
||||
});
|
||||
try {
|
||||
const { AccountDialogDeepLink } = await import("../src/components/people-home-links.tsx");
|
||||
const opened: string[] = [];
|
||||
await h.render(<AccountDialogDeepLink ready open={(dialog) => { opened.push(dialog); }} />);
|
||||
assert.deepEqual(replaced, ["/"]);
|
||||
assert.deepEqual(opened, []);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Home wires it in one line and does not grow", () => {
|
||||
assert.match(page, /import \{ AccountDialogDeepLink, SynastryDeepLink \} from "@\/components\/people-home-links";/);
|
||||
assert.equal(page.match(/<AccountDialogDeepLink ready=\{hydrated && Boolean\(account\)\} open=\{openAccountDialog\} \/>/g)?.length, 1);
|
||||
});
|
||||
@@ -304,6 +304,20 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
|
||||
await renderRoute();
|
||||
return href;
|
||||
}
|
||||
/**
|
||||
* An in-app link to `href` that is not on screen: what `AppLink` does on a
|
||||
* plain click (note the target, then the client router renders and writes
|
||||
* the address bar). The read-only footer was the only bare-`/` link; since
|
||||
* TASK-self-edit-avatar-menu-20260928 S2 it is the account menu.
|
||||
*/
|
||||
async function followHref(href: string) {
|
||||
const { noteClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
|
||||
noteClientNavigationTarget(href);
|
||||
routePath = new URL(href, "http://localhost").pathname;
|
||||
pendingHref = href;
|
||||
await renderRoute();
|
||||
return href;
|
||||
}
|
||||
async function settle(until: () => boolean, label: string, ms = 12000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < ms) {
|
||||
@@ -320,7 +334,7 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { h, view, link, followLink, settle, close, firstCommits, errors: h.errors };
|
||||
return { h, view, link, followLink, followHref, settle, close, firstCommits, errors: h.errors };
|
||||
}
|
||||
|
||||
type Doc = Awaited<ReturnType<typeof openDocument>>;
|
||||
@@ -562,8 +576,11 @@ test("a warm return to bare / after a rectification session lands on the blank s
|
||||
await doc.settle(() => doc!.view().rectificationSurface, "the rectification surface");
|
||||
await doc.followLink("星盘");
|
||||
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
|
||||
// The read-only footer links to bare `/`.
|
||||
await doc.followLink("林遥,打开账户");
|
||||
// 原值: `await doc.followLink("林遥,打开账户")`——只读页脚是回裸 `/` 的链接
|
||||
// 新值: 页脚是账户菜单(不再是链接,下面断言);回裸 `/` 用等价的站内链接跳转 `followHref("/")`
|
||||
// 原因: TASK-self-edit-avatar-menu-20260928 S2——各页头像都弹账户菜单;本测试锁的是「暖回裸 `/` 落空白首页」,与从哪个链接回来无关
|
||||
assert.equal(doc.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), false);
|
||||
await doc.followHref("/");
|
||||
assert.equal(doc.firstCommits[1].loading, false);
|
||||
assert.equal(doc.firstCommits[1].title, "新对话", "the blank starter home is the first frame");
|
||||
for (let i = 0; i < 5; i += 1) await doc.h.idle();
|
||||
|
||||
@@ -266,7 +266,10 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
|
||||
return found;
|
||||
};
|
||||
const button = (label: string) => {
|
||||
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label));
|
||||
// The sidebar footer is the account menu's button on every page now (S2)
|
||||
// and carries the owner's name; page buttons are what these tests click.
|
||||
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label)
|
||||
&& !String(node.props.className ?? "").split(/\s+/).includes("profile-trigger"));
|
||||
assert.ok(found, `button ${label} is rendered`);
|
||||
return found;
|
||||
};
|
||||
@@ -286,6 +289,18 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
|
||||
await renderRoute();
|
||||
return href;
|
||||
}
|
||||
/**
|
||||
* An in-app link to `href` that is not on screen: what `AppLink` does on a
|
||||
* plain click. The read-only footer was the only bare-`/` link; since
|
||||
* TASK-self-edit-avatar-menu-20260928 S2 it is the account menu.
|
||||
*/
|
||||
async function followHref(href: string) {
|
||||
const { noteClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
|
||||
noteClientNavigationTarget(href);
|
||||
tab.history.pushState(null, "", href);
|
||||
await renderRoute();
|
||||
return href;
|
||||
}
|
||||
async function settle(until: () => boolean, label: string, ms = 12000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < ms) {
|
||||
@@ -309,7 +324,7 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
|
||||
await h.event(trigger);
|
||||
assert.equal(drawerOpen(), true, "the mobile drawer opens");
|
||||
}
|
||||
return { h, view, link, button, followLink, settle, close, drawerOpen, openDrawer, errors: h.errors };
|
||||
return { h, view, link, button, followLink, followHref, settle, close, drawerOpen, openDrawer, errors: h.errors };
|
||||
}
|
||||
|
||||
async function openRectificationThenGoTo(tab: Tab, api: Api, secondaryLabel: string, options: { mobile?: boolean } = {}) {
|
||||
@@ -431,10 +446,13 @@ test("a stashed rectification session of the person you left is not reopened as
|
||||
await doc.close();
|
||||
doc = undefined;
|
||||
doc = await openDocument(tab, "/chart");
|
||||
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), "the read-only account link");
|
||||
// 原值: 等只读页脚链接「林遥,打开账户」出现,再 `followLink` 它回裸 `/`
|
||||
// 新值: 等页脚账户菜单按钮出现(且不再是链接),用等价的站内链接 `followHref("/")` 回裸 `/`
|
||||
// 原因: TASK-self-edit-avatar-menu-20260928 S2——各页头像都弹账户菜单;本测试锁的是「回裸 `/` 不重开别人的校正」,与从哪个链接回来无关
|
||||
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && String(node.props.className ?? "").includes("profile-trigger")), "the account menu trigger");
|
||||
assert.equal(doc.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), false);
|
||||
const opensBefore = api.opens();
|
||||
// Read-only footer: the account block links to bare `/`.
|
||||
const href = await doc.followLink("林遥,打开账户");
|
||||
const href = await doc.followHref("/");
|
||||
assert.equal(href, "/");
|
||||
await doc.settle(() => doc!.view().title !== null, "home to reveal");
|
||||
for (let i = 0; i < 10; i += 1) await doc.h.idle();
|
||||
|
||||
@@ -242,12 +242,17 @@ test("the same component renders read-only when `/` is not the one mounting it",
|
||||
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
|
||||
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
|
||||
|
||||
// Only the new-chat action carries intent; the footer remains a home link.
|
||||
// Only the new-chat action carries intent.
|
||||
assert.match(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/);
|
||||
assert.doesNotMatch(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href="\/"/);
|
||||
|
||||
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
|
||||
assert.match(appSidebar, /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/);
|
||||
// 原值: /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/——只读页脚是回 `/` 的链接,无 chevron、无菜单
|
||||
// 新值: 页脚在两种模式都是同一个 `Menu.Root`(`.profile-trigger` 按钮),不再有回 `/` 的页脚链接;只读模式的弹窗项是 `/?account=<dialog>` 链接
|
||||
// 原因: TASK-self-edit-avatar-menu-20260928 S2——产品要求各页点头像都弹账户菜单(推翻侧栏统一单「次级页头像 = 回首页链接」)
|
||||
assert.doesNotMatch(appSidebar, /<AppLink\n\s*className="profile-trigger"/);
|
||||
assert.equal(appSidebar.match(/<Menu\.Root /g)?.length, 1, "one account menu");
|
||||
assert.match(appSidebar, /\{account \? \(\s*(?:\/\*[\s\S]*?\*\/\s*)?<Menu\.Root open=\{accountMenuOpen\}/);
|
||||
assert.match(appSidebar, /render=\{<AppLink href=\{accountDialogHref\(dialog\)\}/);
|
||||
|
||||
// And nothing in read-only mode can write: the write callbacks only exist
|
||||
// inside `controls`, which the secondary layout never passes.
|
||||
|
||||
Reference in New Issue
Block a user