// 运行:node --test scripts/festival-regression.test.mjs;无需硬件。 import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { build } from "esbuild"; const require = createRequire(import.meta.url); // pnpm 中以渲染器的解析路径统一 React 实例,避免测试环境的重复副本。 const react = createRequire(require.resolve("react-dom/server"))("react"); const { createElement } = react; const { renderToStaticMarkup } = require("react-dom/server"); const src = fileURLToPath(new URL("../client/src", import.meta.url)); const base = "/b9/v2/modern/"; async function loadModule(relative) { const result = await build({ entryPoints: [`${src}/${relative}`], bundle: true, write: false, format: "cjs", platform: "node", jsx: "automatic", external: ["react", "react/*"], loader: { ".css": "empty" }, alias: { "@": src }, define: { "import.meta.env.BASE_URL": JSON.stringify(base) }, }); const module = { exports: {} }; const moduleRequire = id => id === "react" ? react : require(id); new Function("require", "module", "exports", result.outputFiles[0].text)(moduleRequire, module, module.exports); return module.exports; } const { getFestival, getNewYearDisplayYear, FESTIVAL_GREETINGS } = await loadModule("lib/festival.ts"); const { NewYearVolumeThumb, NewYearVolumeIcon, NewYearCardDecoration } = await loadModule("components/festival/NewYearDecoration.tsx"); const { default: FestivalGreeting } = await loadModule("components/festival/FestivalGreeting.tsx"); const date = (year, month, day, hour = 12) => new Date(year, month - 1, day, hour); for (const [year, month, day, expected] of [ [2026, 12, 19, null], [2026, 12, 20, "christmas"], [2026, 12, 26, "christmas"], [2026, 12, 27, "newyear"], [2026, 12, 31, "newyear"], [2027, 1, 1, "newyear"], [2027, 1, 3, "newyear"], [2027, 1, 4, null], [2026, 10, 25, null], [2026, 10, 26, "halloween"], [2026, 11, 1, "halloween"], [2026, 11, 2, null], [2027, 12, 27, "newyear"], [2028, 1, 3, "newyear"], ]) { test(`日期窗口 ${year}-${month}-${day} → ${expected}`, () => { for (const hour of [0, 12, 23]) { assert.equal(getFestival(date(year, month, day, hour), ""), expected); } }); } test("URL 强制开关与未知值回退不改变原节日行为", () => { for (const festival of ["newyear", "christmas", "halloween"]) { assert.equal(getFestival(date(2026, 6, 1), `?festival=${festival}`), festival); } for (const off of ["off", "none"]) { assert.equal(getFestival(date(2026, 12, 31), `?festival=${off}`), null); } for (const unknown of ["", "invalid", "toString", "__proto__"]) { assert.equal(getFestival(date(2026, 12, 31), `?festival=${unknown}`), "newyear"); } assert.equal(getFestival(date(2026, 12, 25), "?festival=newyear"), "newyear"); }); test("年份在十二月指向下一年,一月保持同一跨年年份", () => { assert.equal(getNewYearDisplayYear(date(2026, 12, 27)), 2027); assert.equal(getNewYearDisplayYear(date(2026, 12, 31)), 2027); assert.equal(getNewYearDisplayYear(date(2027, 1, 1)), 2027); assert.equal(getNewYearDisplayYear(date(2027, 6, 1)), 2027); assert.equal(getNewYearDisplayYear(date(2027, 12, 31)), 2028); }); test("滑块及大小音量使用独立元旦素材,无圣诞老人、铃铛或万圣节图案", () => { const cases = [ [NewYearVolumeThumb, {}, "party-hat.svg"], [NewYearVolumeIcon, { dim: true }, "sparkler.svg"], [NewYearVolumeIcon, {}, "fireworks.svg"], ]; for (const [Component, props, asset] of cases) { const html = renderToStaticMarkup(createElement(Component, props)); assert.ok(html.includes(`${base}images/festival/newyear/${asset}`)); assert.doesNotMatch(html, /christmas|halloween|santa|bell\.png/); assert.match(html, /draggable="false"/); } const decoration = renderToStaticMarkup(createElement(NewYearCardDecoration)); assert.match(decoration, /aria-hidden="true"/); }); test("三套节日横幅正常渲染,元旦包含动态年份与 JAN 1 日历", () => { for (const festival of ["newyear", "christmas", "halloween"]) { const html = renderToStaticMarkup(createElement(FestivalGreeting, { festival })); assert.ok(html.includes(FESTIVAL_GREETINGS[festival])); assert.ok(html.includes(`${base}images/festival/${festival}/banner-center.jpg`)); if (festival === "newyear") { assert.ok(html.includes(String(getNewYearDisplayYear()))); assert.ok(html.includes("calendar.svg")); assert.match(html, /NEW YEAR · NEW CHAPTER · MORE POSSIBILITIES/); } else { assert.doesNotMatch(html, /newyear-greeting-year|calendar\.svg/); } } }); test("元旦全部素材及五个导航映射存在,部署子路径得到保留", () => { const assets = ["party-hat.svg", "sparkler.svg", "fireworks.svg", "calendar.svg", "clock.svg", "confetti.svg", "banner-left.jpg", "banner-center.jpg", "banner-right.jpg"]; for (const asset of assets) { assert.ok(existsSync(new URL(`../client/public/images/festival/newyear/${asset}`, import.meta.url)), asset); } const nav = readFileSync(`${src}/components/BottomNav.tsx`, "utf8"); const names = [...nav.matchAll(/newyear: festivalIcon\("newyear", "([^"]+)"\)/g)].map(match => match[1]); assert.deepEqual(names, ["confetti", "clock", "calendar", "fireworks", "party-hat"]); });