402 行
17 KiB
TypeScript
402 行
17 KiB
TypeScript
import { ThemeManager, THEME_KEYS, THEMES, ThemeKey, ThemeItem } from '../src/core/theme/theme.manager';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* 辅助函数:计算 Hex 颜色的相对亮度 (Relative Luminance)
|
|
* 遵循 W3C WCAG 2.1 规范算法
|
|
*/
|
|
function getLuminance(hex: string): number {
|
|
const cleanHex = hex.replace('#', '');
|
|
const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
|
|
const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
|
|
const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
|
|
|
|
const a = [r, g, b].map(v => {
|
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
});
|
|
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
|
}
|
|
|
|
/**
|
|
* 辅助函数:计算两组 Hex 颜色的对比度 (Contrast Ratio)
|
|
*/
|
|
function getContrastRatio(hex1: string, hex2: string): number {
|
|
const lum1 = getLuminance(hex1);
|
|
const lum2 = getLuminance(hex2);
|
|
const brightest = Math.max(lum1, lum2);
|
|
const darkest = Math.min(lum1, lum2);
|
|
return (brightest + 0.05) / (darkest + 0.05);
|
|
}
|
|
|
|
describe('ThemeManager (TDD 测试套件: 暖色系与双主题设计系统)', () => {
|
|
let themeManager: ThemeManager;
|
|
|
|
beforeEach(() => {
|
|
themeManager = new ThemeManager();
|
|
});
|
|
|
|
describe('1. 主题 Design Tokens 完备性与格式规范', () => {
|
|
it('应完整支持 cool (冷光暗夜) 与 warm (暖阳赤焰) 双套主题', () => {
|
|
const themes = themeManager.getAvailableThemes();
|
|
const keys = themes.map((t: ThemeItem) => t.key);
|
|
|
|
expect(keys).toContain(THEME_KEYS.COOL);
|
|
expect(keys).toContain(THEME_KEYS.WARM);
|
|
expect(themes.length).toBe(2);
|
|
});
|
|
|
|
it('暖色系 (warm) 必须包含全部核心色彩语义 Tokens', () => {
|
|
const warmTheme = THEMES[THEME_KEYS.WARM];
|
|
expect(warmTheme).toBeDefined();
|
|
|
|
const requiredTokens = [
|
|
'bgPrimary',
|
|
'bgSurface',
|
|
'bgSurfaceElevated',
|
|
'bgSurfaceGlass',
|
|
'colorBrand',
|
|
'colorBrandLight',
|
|
'colorBrandGlow',
|
|
'borderSubtle',
|
|
'textPrimary',
|
|
'textSecondary',
|
|
'textMuted'
|
|
];
|
|
|
|
for (const token of requiredTokens) {
|
|
expect(warmTheme.tokens[token as keyof typeof warmTheme.tokens]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('暖色系关键颜色必须符合合法的 Hex 与 rgba 语法格式', () => {
|
|
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
|
const hexRegex = /^#([0-9A-Fa-f]{6})$/;
|
|
const rgbaRegex = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
|
|
|
|
expect(hexRegex.test(tokens.bgPrimary)).toBe(true);
|
|
expect(hexRegex.test(tokens.bgSurface)).toBe(true);
|
|
expect(hexRegex.test(tokens.colorBrand)).toBe(true);
|
|
expect(hexRegex.test(tokens.textPrimary)).toBe(true);
|
|
expect(rgbaRegex.test(tokens.colorBrandGlow)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('2. WCAG AA 级无障碍色彩对比度与浅色特征验证', () => {
|
|
it('浅色暖色系底色必须为明亮浅色 (相对亮度 > 0.85)', () => {
|
|
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
|
const bgLum = getLuminance(tokens.bgPrimary);
|
|
const surfaceLum = getLuminance(tokens.bgSurface);
|
|
|
|
expect(bgLum).toBeGreaterThanOrEqual(0.85);
|
|
expect(surfaceLum).toBeGreaterThanOrEqual(0.95);
|
|
});
|
|
|
|
it('浅色暖色系一级主文本 (textPrimary) 对浅色底与卡片底色对比度必须 >= 7:1 (超越 AA 级 4.5:1)', () => {
|
|
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
|
const ratioOnBg = getContrastRatio(tokens.textPrimary, tokens.bgPrimary);
|
|
const ratioOnSurface = getContrastRatio(tokens.textPrimary, tokens.bgSurface);
|
|
|
|
expect(ratioOnBg).toBeGreaterThanOrEqual(7.0);
|
|
expect(ratioOnSurface).toBeGreaterThanOrEqual(7.0);
|
|
});
|
|
|
|
it('浅色暖色系二级辅助文本 (textSecondary) 对浅色底对比度必须 >= 4.5:1 (符合 WCAG AA 标准)', () => {
|
|
const tokens = THEMES[THEME_KEYS.WARM].tokens;
|
|
const ratio = getContrastRatio(tokens.textSecondary, tokens.bgPrimary);
|
|
|
|
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
});
|
|
|
|
describe('3. 主题切换状态机与防御性控制', () => {
|
|
it('默认初始主题应为 cool (冷光暗夜)', () => {
|
|
expect(themeManager.getCurrentThemeKey()).toBe(THEME_KEYS.COOL);
|
|
expect(themeManager.getCurrentTheme().name).toBe('冷光暗夜');
|
|
});
|
|
|
|
it('成功切换到 warm (暖阳晨曦) 浅色暖色系主题', () => {
|
|
const current = themeManager.switchTheme(THEME_KEYS.WARM);
|
|
expect(current.key).toBe(THEME_KEYS.WARM);
|
|
expect(current.name).toBe('暖阳晨曦');
|
|
expect(themeManager.getCurrentThemeKey()).toBe(THEME_KEYS.WARM);
|
|
expect(themeManager.getCurrentTokens().colorBrand).toBe('#EA580C');
|
|
});
|
|
|
|
it('传入非法主题标识时,应平滑回退到默认 cool 主题,确保系统不崩溃', () => {
|
|
const fallback = themeManager.switchTheme('non_existent_theme' as ThemeKey);
|
|
expect(fallback.key).toBe(THEME_KEYS.COOL);
|
|
});
|
|
});
|
|
|
|
describe('4. 前端代码纯净度与 Emoji 门禁校验', () => {
|
|
it('主题配置文件中严禁包含任何系统 Emoji', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
|
|
|
|
const filesToCheck = [
|
|
path.join(rootDir, 'server', 'src', 'core', 'theme', 'theme.manager.ts'),
|
|
path.join(rootDir, 'pwuniapp', 'common', 'theme.js')
|
|
];
|
|
|
|
for (const file of filesToCheck) {
|
|
if (fs.existsSync(file)) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
const lines = content.split('\n');
|
|
lines.forEach((line, idx) => {
|
|
expect({
|
|
file: path.relative(rootDir, file),
|
|
line: idx + 1,
|
|
hasEmoji: emojiRegex.test(line)
|
|
}).toEqual({
|
|
file: path.relative(rootDir, file),
|
|
line: idx + 1,
|
|
hasEmoji: false
|
|
});
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('5. 顶部导航栏 (NavBar) 与底部菜单 (TabBar) 双主题规范校验', () => {
|
|
it('冷色系 NavBar 必须为深色黑底白字,TabBar 必须为深蓝卡片底', () => {
|
|
const coolTheme = THEMES[THEME_KEYS.COOL];
|
|
expect(coolTheme.tokens.navBar).toBeDefined();
|
|
expect(coolTheme.tokens.navBar.backgroundColor).toBe('#0A0F1D');
|
|
expect(coolTheme.tokens.navBar.frontColor).toBe('#ffffff');
|
|
|
|
expect(coolTheme.tokens.tabBar).toBeDefined();
|
|
expect(coolTheme.tokens.tabBar.backgroundColor).toBe('#151D30');
|
|
expect(coolTheme.tokens.tabBar.selectedColor).toBe('#6366F1');
|
|
});
|
|
|
|
it('浅色暖阳晨曦模式下,顶部 NavBar 与底部 TabBar 必须全部为浅色亮底', () => {
|
|
const warmTheme = THEMES[THEME_KEYS.WARM];
|
|
expect(warmTheme.tokens.navBar).toBeDefined();
|
|
expect(warmTheme.tokens.tabBar).toBeDefined();
|
|
|
|
// 顶部 NavBar 浅色底 (亮度 >= 0.85),文字为深黑 (#000000)
|
|
expect(getLuminance(warmTheme.tokens.navBar.backgroundColor)).toBeGreaterThanOrEqual(0.85);
|
|
expect(warmTheme.tokens.navBar.frontColor).toBe('#000000');
|
|
|
|
// 底部 TabBar 浅色底 (纯白或米白,亮度 >= 0.95),选中项高亮为曜橙
|
|
expect(getLuminance(warmTheme.tokens.tabBar.backgroundColor)).toBeGreaterThanOrEqual(0.95);
|
|
expect(warmTheme.tokens.tabBar.selectedColor).toBe('#EA580C');
|
|
expect(warmTheme.tokens.tabBar.borderStyle).toBe('white');
|
|
});
|
|
});
|
|
|
|
describe('6. 核心 Tab 页面多端生命周期与主题自适应门禁校验', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const tabPages = [
|
|
path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue'),
|
|
path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'list.vue'),
|
|
path.join(rootDir, 'pwuniapp', 'pages', 'order', 'list.vue'),
|
|
path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue'),
|
|
];
|
|
|
|
it('四大 Tab 页面必须全部引入并在 onShow 中调用 applyThemeToNativeUI', () => {
|
|
for (const pagePath of tabPages) {
|
|
expect(fs.existsSync(pagePath)).toBe(true);
|
|
const content = fs.readFileSync(pagePath, 'utf-8');
|
|
const relativeName = path.relative(rootDir, pagePath);
|
|
|
|
expect({
|
|
file: relativeName,
|
|
hasApplyTheme: content.includes('applyThemeToNativeUI'),
|
|
hasOnShow: content.includes('onShow')
|
|
}).toEqual({
|
|
file: relativeName,
|
|
hasApplyTheme: true,
|
|
hasOnShow: true
|
|
});
|
|
}
|
|
});
|
|
|
|
it('四大 Tab 页面必须包含 theme-warm 根容器类绑定与浅色样式适配', () => {
|
|
for (const pagePath of tabPages) {
|
|
const content = fs.readFileSync(pagePath, 'utf-8');
|
|
const relativeName = path.relative(rootDir, pagePath);
|
|
|
|
expect({
|
|
file: relativeName,
|
|
hasThemeWarmBind: content.includes('theme-warm'),
|
|
hasThemeWarmStyle: content.includes('.theme-warm')
|
|
}).toEqual({
|
|
file: relativeName,
|
|
hasThemeWarmBind: true,
|
|
hasThemeWarmStyle: true
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('7. 浅色暖色系深度视觉与无障碍体验门禁校验', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const indexVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue');
|
|
const userVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue');
|
|
const activityVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'list.vue');
|
|
const appVuePath = path.join(rootDir, 'pwuniapp', 'App.vue');
|
|
|
|
it('首页在 theme-warm 下必须对金刚区卡片与大神卡片进行浅色纯白重构', () => {
|
|
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
|
expect(content).toContain('.theme-warm');
|
|
// 必须包含针对 grid-card 与 expert-card 的纯白背景或深文本适配
|
|
expect(content.includes('grid-card') && content.includes('#FFFFFF')).toBe(true);
|
|
expect(content.includes('expert-card') && content.includes('#1C1917')).toBe(true);
|
|
});
|
|
|
|
it('个人中心身份横幅在 theme-warm 下必须进行浅暖微光重构', () => {
|
|
const content = fs.readFileSync(userVuePath, 'utf-8');
|
|
expect(content.includes('identity-banner') && content.includes('#1C1917')).toBe(true);
|
|
});
|
|
|
|
it('活动列表页悬浮按钮必须包含大屏机壳边界约束', () => {
|
|
const content = fs.readFileSync(activityVuePath, 'utf-8');
|
|
// 必须针对大屏仿真外壳有边界控制(如 max-width 450px 居中计算或媒体查询)
|
|
expect(content.includes('min-width: 481px') || content.includes('calc(50%')).toBe(true);
|
|
});
|
|
|
|
it('App.vue 必须包含针对 uni-page-head 强制米白底和深黑标题字的强穿透规则', () => {
|
|
const content = fs.readFileSync(appVuePath, 'utf-8');
|
|
expect(content).toContain('uni-page-head');
|
|
expect(content).toContain('#FDFBF7');
|
|
expect(content).toContain('#1C1917');
|
|
});
|
|
});
|
|
|
|
describe('8. 浅色暖色系文字可读性与 WCAG 对比度严格门禁 (TDD)', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const indexVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue');
|
|
const userVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue');
|
|
const themeJsPath = path.join(rootDir, 'pwuniapp', 'common', 'theme.js');
|
|
|
|
it('首页大神卡片昵称选择器必须准确匹配 DOM,且文字在白底上的对比度必须 >= 7:1', () => {
|
|
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
|
// 严禁存在错误且未命中的 .expert-info 选择器
|
|
expect(content.includes('.expert-info .nickname')).toBe(false);
|
|
// 必须准确匹配 .card-body .nickname 或 .row-nickname .nickname 或 .expert-card .nickname
|
|
const hasValidNicknameSelector =
|
|
content.includes('.card-body .nickname') ||
|
|
content.includes('.row-nickname .nickname') ||
|
|
content.includes('.expert-card .nickname');
|
|
expect(hasValidNicknameSelector).toBe(true);
|
|
|
|
// 提取浅色模式下昵称的文字颜色并断言高对比度
|
|
const nicknameColor = '#1C1917';
|
|
const contrastRatio = getContrastRatio(nicknameColor, '#FFFFFF');
|
|
expect(contrastRatio).toBeGreaterThanOrEqual(7.0);
|
|
});
|
|
|
|
it('首页大神卡片段位与城市标签在白底上的文字对比度必须符合 WCAG AA 级 (>= 4.5:1)', () => {
|
|
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
|
// 浅色模式下必须对 rank-badge 和 city-badge 进行对比度增强定义
|
|
expect(content.includes('.rank-badge')).toBe(true);
|
|
expect(content.includes('.city-badge')).toBe(true);
|
|
|
|
// 验证暖阳主题专用的高对比深琥珀色 (rank-badge) 与深天蓝色 (city-badge, Sky-700)
|
|
const rankColorWarm = '#B45309';
|
|
const cityColorWarm = '#0369A1';
|
|
expect(getContrastRatio(rankColorWarm, '#FFFFFF')).toBeGreaterThanOrEqual(4.5);
|
|
expect(getContrastRatio(cityColorWarm, '#FFFFFF')).toBeGreaterThanOrEqual(4.5);
|
|
|
|
// 确保样式表中在 .theme-warm 下包含了这两个颜色的应用
|
|
expect(content).toContain(rankColorWarm);
|
|
expect(content).toContain(cityColorWarm);
|
|
});
|
|
|
|
it('首页快捷金刚区卡片在浅色暖色系下必须显式重构为纯白底与深色文字', () => {
|
|
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
|
// 检查 theme-warm 样式块中必须显式声明 .quick-grid 的白底
|
|
const warmSection = content.split('.index-container.theme-warm')[1] || '';
|
|
expect(warmSection).toContain('.quick-grid');
|
|
expect(warmSection).toContain('#FFFFFF');
|
|
});
|
|
|
|
it('个人中心双身份切换卡在浅色模式下必须显式重置 &::after 伪元素遮罩背景', () => {
|
|
const content = fs.readFileSync(userVuePath, 'utf-8');
|
|
const warmSection = content.split('.user-container.theme-warm')[1] || '';
|
|
// 必须在 theme-warm 的 .role-switch-card 下显式重写 &::after,避免深色渐变残留导致黑底黑字
|
|
const hasAfterResetInWarm = warmSection.includes('.role-switch-card') && warmSection.includes('&::after');
|
|
expect(hasAfterResetInWarm).toBe(true);
|
|
});
|
|
|
|
it('theme.js 中必须包含针对 H5 document 根节点动态注入主题 class 的逻辑,杜绝导航栏黑底黑字', () => {
|
|
const content = fs.readFileSync(themeJsPath, 'utf-8');
|
|
// 必须支持在 H5 端将主题样式注入到 document.body 或 documentElement
|
|
const hasH5DomSupport = content.includes('document.body.classList') || content.includes('document.documentElement.classList');
|
|
expect(hasH5DomSupport).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('9. 四大核心 TabBar 页面去掉原生 NavBar (custom) 门禁校验', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json');
|
|
|
|
it('pages.json 中首页、活动项目、订单中心、我的 必须全部配置 navigationStyle: custom', () => {
|
|
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
|
|
const pagesConfig = JSON.parse(raw);
|
|
|
|
const targetPaths = [
|
|
'pages/index/index',
|
|
'pages/activity/list',
|
|
'pages/order/list',
|
|
'pages/user/index'
|
|
];
|
|
|
|
for (const targetPath of targetPaths) {
|
|
const pageItem = pagesConfig.pages.find((p: any) => p.path === targetPath);
|
|
expect(pageItem).toBeDefined();
|
|
expect({
|
|
page: targetPath,
|
|
navigationStyle: pageItem.style?.navigationStyle
|
|
}).toEqual({
|
|
page: targetPath,
|
|
navigationStyle: 'custom'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('10. 顶部安全距离小程序与 H5 多端兼容门禁校验', () => {
|
|
const rootDir = path.resolve(__dirname, '../../');
|
|
const appVuePath = path.join(rootDir, 'pwuniapp', 'App.vue');
|
|
const indexVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue');
|
|
const activityVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'list.vue');
|
|
const orderVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'order', 'list.vue');
|
|
const userVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue');
|
|
|
|
it('App.vue 必须在全局定义顶部安全间距变量与小程序/H5适配规则', () => {
|
|
const content = fs.readFileSync(appVuePath, 'utf-8');
|
|
expect(content).toContain('--safe-header-gap');
|
|
expect(content).toContain('--status-bar-height');
|
|
});
|
|
|
|
it('四大 TabBar 页面顶部必须全部采用 --safe-header-gap 规范化安全留白', () => {
|
|
const files = [indexVuePath, activityVuePath, orderVuePath, userVuePath];
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
const relativeName = path.relative(rootDir, file);
|
|
expect({
|
|
file: relativeName,
|
|
hasSafeHeaderGap: content.includes('--safe-header-gap')
|
|
}).toEqual({
|
|
file: relativeName,
|
|
hasSafeHeaderGap: true
|
|
});
|
|
}
|
|
});
|
|
|
|
it('首页必须针对小程序端具备胶囊按钮避让属性', () => {
|
|
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
|
expect(content.includes('capsule-space') || content.includes('MP-WEIXIN')).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
|