summaryrefslogtreecommitdiff
path: root/frontend/src/lib/mobileCss.test.ts
blob: 3587f0f88f42f721a2fb9859411d7fdbd4e256df (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// @ts-nocheck
import { describe, expect, it } from 'vitest';
// round6:旅程重排 + token 规范守门(docs/ui-user-journey-round6.md §2 白名单)。
// app.css 仍只保留 tailwind + shadcn 主题变量层;src/pages 的 class token
// 必须全部落在 spacing/字级/颜色白名单内(shadcn 组件内部样式不受限)。
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { globSync } from 'node:fs';

const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src');
const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8');

const pageSources = globSync('pages/*.svelte', { cwd: srcRoot }).map((p) =>
  readFileSync(path.join(srcRoot, p), 'utf8')
);

function extractClasses(src) {
  const out = new Set();
  const re = /class="([^"]*)"/g;
  let m;
  while ((m = re.exec(src))) {
    for (const c of m[1].split(/\s+/)) if (c) out.add(c);
  }
  return [...out];
}

describe('round6 守门:token 白名单(docs/ui-user-journey-round6.md §2)', () => {
  it('app.css 保持仅 tailwind 导入 + shadcn 主题变量层', () => {
    const stripped = appCss
      .replace(/\/\*[\s\S]*?\*\//g, '')
      .replace(/@import[^;]+;/g, '')
      .replace(/@custom-variant[^;]+;/g, '')
      .replace(/:root\s*,?\s*[\s\S]*?\}/, '')
      .replace(/\.dark\s*\{[^}]*\}/, '')
      .replace(/@theme\s+inline\s*\{[\s\S]*\}/, '')
      .replace(/@layer\s+base\s*\{[\s\S]*\}/, '');
    expect(stripped.trim().replace(/\s+/g, ' ')).toBe('');
    expect(appCss.includes('overflow-x: hidden')).toBe(false);
  });

  it('字号只允许 text-xs/sm/lg/2xl(文档 §2 字级白名单)', () => {
    const allowed = ['text-xs', 'text-sm', 'text-lg', 'text-2xl'];
    const offenders = new Set();
    for (const src of pageSources) {
      for (const c of extractClasses(src)) {
        if (/^text-(?!shadcn)[a-z0-9/.[]/.test(c) && !/^(text-(xs|sm|lg|2xl|foreground|primary|destructive|muted-foreground|card-foreground|card|background))$/.test(c)) {
          offenders.add(c);
        }
      }
    }
    const extra = [...offenders].filter((c) => !allowed.some((a) => c.startsWith(a + ' ') || c === a) && !/^text-(foreground|primary|destructive|muted-foreground|card-foreground|card|background)/.test(c));
    expect(extra, 'font-size/color tokens must stay on the white-list').toEqual([]);
  });

  it('语义颜色只允许 text-primary/text-destructive/text-muted-foreground/bg-background/bg-card', () => {
    const offenders = new Set();
    const rawPalettes =
      '(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|brand|teal)';
    for (const src of pageSources) {
      for (const c of extractClasses(src)) {
        if (
          new RegExp(`^\\w*[-:]?${rawPalettes}`).test(c) ||
          /^bg-(?!background|card)([a-z-]+)/.test(c) ||
          /\/\d+/.test(c)
        ) {
          if (!/^text-(color)/.test(c)) offenders.add(c);
        }
      }
    }
    const extra = [...offenders].filter((c) => !/^(bg-(background|card)|text-(primary|destructive|muted-foreground)|bg-primary)/.test(c) && !new RegExp(`[-/:]${rawPalettes}`).test(c) && !c.includes('/'));
    expect(extra, 'raw tailwind palette colors are banned in pages').toEqual([]);
  });

  it('顶级容器间距只用 gap-4/gap-6(禁止 gap-2/gap-3),页面不写 rounded-*', () => {
    const offenders = new Set();
    for (const src of pageSources) {
      for (const c of extractClasses(src)) {
        if (/^gap-[23](\s|$)/.test(c)) offenders.add(c);
        if (/^rounded-/.test(c)) offenders.add(c);
      }
    }
    expect([...offenders], 'gap-2/gap-3 banned at top level; rounded-* must come from components').toEqual([]);
  });

  it('历史 legacy 组件类名不回归', () => {
    const legacy = ['btn', 'banner', 'metric-card', 'm-tile', 'metric-row', 'mvalue', 'mlabel', 'hint', 'stack', 'spread', 'grid3', 'cols-2', 'crumbs', 'crumb-name', 'save-status', 'step-h', 'chip-list', 'err-list', 'cov-caption', 'cov-list', 'loading-page'];
    const offenders = new Set();
    for (const src of pageSources) {
      for (const c of extractClasses(src)) {
        if (legacy.includes(c)) offenders.add(c);
      }
    }
    expect([...offenders]).toEqual([]);
  });
});