Files
ttstd-infinite-canvas/web/test/banner-notice.test.ts

148 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
BANNER_NOTICE_DEFAULT_TYPE,
BANNER_NOTICE_EMOJI_GROUPS,
BANNER_NOTICE_EMOJIS,
BANNER_NOTICE_TYPE_VALUES,
BANNER_NOTICE_TYPES,
bannerNoticeSurfaceCSS,
bannerNoticeTypeLabel,
normalizeBannerNoticeType,
searchBannerNoticeEmojis,
} from "@/lib/announcements/banner-notice";
import { bannerTitleContrastRatio } from "@/lib/announcements/banner-title";
async function readSource(relativePath: string) {
return Bun.file(new URL(relativePath, import.meta.url)).text();
}
/** 从 Go 的 `var X = map[string]struct{}{...}` 字面量里取出键集合。 */
function goMapKeys(source: string, variable: string) {
const start = source.indexOf(`var ${variable} = map[string]struct{}{`);
if (start < 0) throw new Error(`未找到 ${variable}`);
const end = source.indexOf("\n}", start);
if (end < 0) throw new Error(`${variable} 字面量未闭合`);
const body = source.slice(start, end);
return Array.from(body.matchAll(/"([a-z0-9-]+)"/g)).map((match) => match[1]);
}
describe("通知类型", () => {
test("未知或空值回落到默认类型", () => {
expect(normalizeBannerNoticeType(undefined)).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType("")).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType("rainbow")).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType(" ACTIVITY ")).toBe("activity");
});
test("每种类型都有中文名与独立的底色 token", () => {
const vars = BANNER_NOTICE_TYPES.map((item) => item.surfaceVar);
expect(new Set(vars).size).toBe(vars.length);
for (const type of BANNER_NOTICE_TYPES) {
expect(bannerNoticeTypeLabel(type.key)).toBe(type.label);
expect(bannerNoticeSurfaceCSS(type.key)).toBe(`var(${type.surfaceVar})`);
}
// 未知类型也必须拿到可用底色,不能渲染成透明条。
expect(bannerNoticeSurfaceCSS("rainbow")).toBe(bannerNoticeSurfaceCSS(BANNER_NOTICE_DEFAULT_TYPE));
});
});
describe("emoji 图标素材库", () => {
test("字符唯一、非空,每个素材都有中文名", () => {
const chars = BANNER_NOTICE_EMOJIS.map((item) => item.char);
expect(new Set(chars).size).toBe(chars.length);
expect(chars.length).toBeGreaterThanOrEqual(50);
for (const item of BANNER_NOTICE_EMOJIS) {
expect(item.char.trim().length).toBeGreaterThan(0);
expect(item.label.trim().length).toBeGreaterThan(0);
expect(item.groupLabel.trim().length).toBeGreaterThan(0);
}
});
test("用户点名的素材必须在库内", () => {
for (const char of ["💰", "🚀", "🔥", "📢"]) {
expect(BANNER_NOTICE_EMOJIS.some((item) => item.char === char)).toBe(true);
}
});
test("搜索同时匹配中文名、分组名与字符本身", () => {
expect(searchBannerNoticeEmojis("火箭").map((item) => item.char)).toContain("🚀");
expect(searchBannerNoticeEmojis("钱袋").map((item) => item.char)).toContain("💰");
// 分组名搜索命中整组。
const statusHits = searchBannerNoticeEmojis("状态");
expect(statusHits.length).toBeGreaterThanOrEqual(BANNER_NOTICE_EMOJI_GROUPS.find((g) => g.key === "status")!.emoji.length);
// 字符本身也能搜到(支持从别处粘贴检索)。
expect(searchBannerNoticeEmojis("🔥").map((item) => item.char)).toContain("🔥");
expect(searchBannerNoticeEmojis(" ").length).toBe(BANNER_NOTICE_EMOJIS.length);
expect(searchBannerNoticeEmojis("不存在的素材")).toEqual([]);
});
test("emoji 是普通文本:标题纯文本拼接函数必须原样保留它", async () => {
// 防止未来有人给 run.text 做字符过滤,把 emoji 丢掉。
const { bannerTitlePlainText } = await import("@/lib/announcements/banner-title");
const runs = [
{ text: "🔥", fontSize: 20 },
{ text: "限时", color: "#FFE58F" },
{ text: "大促" },
];
expect(bannerTitlePlainText(runs as never)).toBe("🔥限时大促");
});
});
describe("与后端白名单保持一致", () => {
test("通知类型的后端白名单逐项等于前端注册表", async () => {
const goSource = await readSource("../../backend/internal/app/announcement.go");
expect(new Set(goMapKeys(goSource, "bannerNoticeTypes"))).toEqual(new Set(BANNER_NOTICE_TYPE_VALUES));
});
test("后端默认值常量与前端一致", async () => {
const goSource = await readSource("../../backend/internal/app/announcement.go");
expect(goSource).toContain(`bannerNoticeDefaultType = "${BANNER_NOTICE_DEFAULT_TYPE}"`);
});
test("后端不保存独立图标字段:emoji 必须内嵌在标题分段里", async () => {
const [goModel, goApp, goRepo] = await Promise.all([
readSource("../../backend/internal/model/models_project.go"),
readSource("../../backend/internal/app/announcement.go"),
readSource("../../backend/internal/repository/announcement.go"),
]);
// 防止 icon 字段被加回来却没人维护白名单:图标的存储形态就是 TitleRuns 的文本。
expect(goModel).not.toMatch(/\bIcon\s+string/);
expect(goApp).not.toContain("normalizeBannerIcon");
expect(goRepo).not.toContain('"icon"');
});
});
describe("通知条主题色", () => {
test("四种类型在明暗两档都满足白字对比度要求", async () => {
const css = await readSource("../src/styles/globals.css");
const value = (token: string) => {
const match = css.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`));
if (!match) throw new Error(`globals.css 缺少 --${token}`);
return match[1];
};
for (const type of BANNER_NOTICE_TYPES) {
// 浅色档是正文级底色,必须达到 WCAG AA 的 4.5:1;
// 深色档统一压到 7:1 以上,避免暗色主题里底色过亮。
const light = value(`palette-banner-${type.key}-500`);
const dark = value(`palette-banner-${type.key}-700`);
expect(bannerTitleContrastRatio("#FFFFFF", light)).toBeGreaterThanOrEqual(4.5);
expect(bannerTitleContrastRatio("#FFFFFF", dark)).toBeGreaterThanOrEqual(7);
}
});
test("前台与预览都引用按类型取值的语义 token", async () => {
const [globals, adminUi] = await Promise.all([readSource("../src/styles/globals.css"), readSource("../src/styles/admin-ui.css")]);
for (const type of BANNER_NOTICE_TYPES) {
// surfaceVar 自带 `--` 前缀,拼接时不要再加一次。
const name = type.surfaceVar.replace(/^--/, "");
// 语义层明暗各定义一次::root 一次、.dark 一次。
const semantic = new RegExp(`--${name}: var\\(--palette-banner-${type.key}-\\d00\\)`, "g");
expect(globals.match(semantic)?.length).toBe(2);
// 后台预览强制两套底色,不受页面当前主题影响。
expect(adminUi).toContain(`--${name}: var(--palette-banner-${type.key}-500)`);
expect(adminUi).toContain(`--${name}: var(--palette-banner-${type.key}-700)`);
}
});
});