桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
106 lines
5.6 KiB
TypeScript
106 lines
5.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { UserAvatar } from "../src/components/user-avatar.tsx";
|
|
import {
|
|
BEAM_AVATAR_RENDERER_VERSION,
|
|
beamAvatarPalettes,
|
|
beamAvatarPatchSchema,
|
|
beamAvatarSchema,
|
|
createBeamAvatarProfilePatch,
|
|
} from "../src/lib/beam-avatar.ts";
|
|
|
|
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
|
const accountRoute = readProjectFile("src/app/api/account/route.ts");
|
|
const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts");
|
|
const avatarComponent = readProjectFile("src/components/user-avatar.tsx");
|
|
const sidebar = readProjectFile("src/components/app-sidebar.tsx");
|
|
const page = readProjectFile("src/app/(app)/page.tsx");
|
|
const profilePanel = readProjectFile("src/components/profile-panel.tsx");
|
|
const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql");
|
|
|
|
const seed = "11111111-1111-4111-8111-111111111111";
|
|
|
|
test("defines eight reviewed Beam palettes and versioned persisted parameters", () => {
|
|
assert.equal(beamAvatarPalettes.length, 8);
|
|
assert.equal(new Set(beamAvatarPalettes.map((palette) => palette.name)).size, 8);
|
|
assert.equal(beamAvatarPalettes.every((palette) => palette.colors.length === 5), true);
|
|
assert.deepEqual(beamAvatarSchema.parse({
|
|
seed,
|
|
palette: 7,
|
|
rendererVersion: BEAM_AVATAR_RENDERER_VERSION,
|
|
}), { seed, palette: 7, rendererVersion: 1 });
|
|
assert.equal(beamAvatarSchema.safeParse({ seed, palette: 8, rendererVersion: 1 }).success, false);
|
|
assert.equal(beamAvatarSchema.safeParse({ seed: "email@example.test", palette: 0, rendererVersion: 1 }).success, false);
|
|
});
|
|
|
|
test("renders identical Beam SVG for identical persisted parameters", () => {
|
|
const avatar = { seed, palette: 0, rendererVersion: 1 } as const;
|
|
const first = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
|
|
const second = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
|
|
|
|
assert.equal(first, second);
|
|
assert.match(first, /<svg/);
|
|
assert.match(first, /width="40"/);
|
|
assert.match(first, /height="40"/);
|
|
});
|
|
|
|
test("accepts only bounded avatar actions and creates new seeds on the server", () => {
|
|
assert.equal(beamAvatarPatchSchema.safeParse({ action: "set_palette", palette: 3 }).success, true);
|
|
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize" }).success, true);
|
|
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize", seed }).success, false);
|
|
assert.equal(beamAvatarPatchSchema.safeParse({ action: "upload" }).success, false);
|
|
|
|
assert.deepEqual(
|
|
createBeamAvatarProfilePatch({ action: "set_palette", palette: 3 }, () => seed),
|
|
{ avatar_palette: 3, avatar_renderer_version: 1 },
|
|
);
|
|
assert.deepEqual(
|
|
createBeamAvatarProfilePatch({ action: "randomize" }, () => seed),
|
|
{ avatar_renderer_version: 1, avatar_seed: seed },
|
|
);
|
|
});
|
|
|
|
test("persists random defaults and constraints only for self-hosted profiles", () => {
|
|
assert.match(migration, /to_regclass\('identity\.users'\) is not null/);
|
|
assert.match(migration, /avatar_seed uuid not null default gen_random_uuid\(\)/);
|
|
assert.match(migration, /avatar_palette smallint not null[\s\S]*floor\(random\(\) \* 8\)/);
|
|
assert.match(migration, /check \(avatar_palette between 0 and 7\)/);
|
|
assert.match(migration, /check \(avatar_renderer_version = 1\)/);
|
|
assert.match(migration, /grant update \([\s\S]*avatar_seed[\s\S]*avatar_palette[\s\S]*avatar_renderer_version[\s\S]*\) on table public\.profiles to authenticated/);
|
|
assert.doesNotMatch(migration, /bytea|storage|bucket|upload/i);
|
|
});
|
|
|
|
test("account APIs expose and update Beam parameters through authenticated staging data", () => {
|
|
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
|
assert.match(accountRoute, /select\("avatar_seed,avatar_palette,avatar_renderer_version"\)/);
|
|
assert.match(accountRoute, /avatar,/);
|
|
|
|
assert.match(avatarRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
|
assert.match(avatarRoute, /createServerSupabaseClient\(\)/);
|
|
assert.match(avatarRoute, /auth\.getUser\(\)/);
|
|
assert.match(avatarRoute, /beamAvatarPatchSchema\.safeParse/);
|
|
assert.match(avatarRoute, /createBeamAvatarProfilePatch\(parsed\.data\)/);
|
|
assert.match(avatarRoute, /\.eq\("id", user\.id\)/);
|
|
assert.doesNotMatch(avatarRoute, /createAdminSupabaseClient|SUPABASE_SERVICE_ROLE_KEY|upload|bytea/i);
|
|
});
|
|
|
|
test("renders the persisted Beam avatar in the sidebar, account menu, and profile editor", () => {
|
|
assert.match(avatarComponent, /from "boring-avatars"/);
|
|
assert.match(avatarComponent, /variant="beam"/);
|
|
assert.match(avatarComponent, /name=\{avatar\.seed\}/);
|
|
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{32\}/);
|
|
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{40\}/);
|
|
assert.match(page, /<ProfilePanel[\s\S]*persistAvatar=\{persistAvatar\}/);
|
|
// 原值:必须出现「Beam 形象会在刷新和换设备后保持一致」
|
|
// 新值:这句说明不再出现
|
|
// 原因:D8 去掉重复解释布局的小字。配色、随机和侧栏头像尺寸仍在
|
|
assert.doesNotMatch(profilePanel, /Beam 形象会在刷新和换设备后保持一致/);
|
|
assert.match(profilePanel, /role="radiogroup" aria-label="头像配色"/);
|
|
assert.match(profilePanel, /persistAvatar\(\{ action: "set_palette", palette: index \}\)/);
|
|
assert.match(profilePanel, /persistAvatar\(\{ action: "randomize" \}\)/);
|
|
assert.doesNotMatch(`${page}\n${profilePanel}`, /avatar[^\n]{0,80}(?:file|upload|上传图片)/i);
|
|
});
|