import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; /** * گاردِ تم: رنگ‌ها باید از توکن‌های `styles.css` بیایند تا دارک‌مود همه‌جا کار کند. * hex ثابت و کلاس‌های پالت Tailwind با تغییر تم عوض نمی‌شوند. */ const ROOT = join(__dirname, '..'); /** رنگ‌های عمداً ثابت: دادهٔ کاربر یا پوششی که در هر دو تم یکسان است. */ const INTENTIONAL = [ 'pages/TagsSettingsPage.tsx', // TAG_COLORS — پالت انتخابی کاربر برای برچسب 'stores/uiStore.ts', // BrandFixed — هیوی نارنجی عمداً hex ثابت است 'lib/avatarColors.ts', // گرادیان تزئینی آواتار، در هر دو تم یکسان ]; /** پالت پیش‌فرض Tailwind v4 — هر خانوادهٔ دیگری باید در `@theme` تعریف شده باشد. */ const TAILWIND_FAMILIES = new Set([ 'slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', ]); const HEX = /#[0-9a-fA-F]{3,8}\b/; const PALETTE_UTIL = /(?:^|[\s"'`{])(?:dark:|hover:|focus:|active:|group-hover:|md:|lg:|sm:)*(?:bg|text|border|divide|ring|fill|stroke)-(?:white|black|gray|slate|zinc|neutral|stone|red|green|emerald|blue|sky|amber|yellow|orange|purple|indigo|violet|pink|rose|teal|cyan)(?:-\d{2,3})?\b/; function sourceFiles(): string[] { const out: string[] = []; (function walk(dir: string) { for (const entry of readdirSync(dir)) { const p = join(dir, entry); if (statSync(p).isDirectory()) { walk(p); continue; } if (!/\.tsx?$/.test(entry) || /\.test\.tsx?$/.test(entry)) continue; const rel = relative(ROOT, p); if (rel.startsWith('test/')) continue; out.push(rel); } })(ROOT); return out; } /** * کامنت‌ها و fallbackهای `var(--x, #hex)` رنگِ رندرشده نیستند؛ `bg-black/NN` هم * اسکریم است و عمداً در هر دو تم یکسان می‌ماند (مثل `.overlay` در styles.css). */ function strip(src: string): string { return src .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, '$1') .replace(/var\(\s*--[a-z0-9-]+\s*,[^)]*\)/gi, 'var(--token)') .replace(/\bbg-black\/\d+/g, ''); } describe('theme tokens', () => { const files = sourceFiles(); it('سورس ادمین را واقعاً پیدا می‌کند', () => { expect(files.length).toBeGreaterThan(100); }); it('هیچ کلاس dark: مرده‌ای کنار inline style رنگی نمانده است', () => { const offenders = files.filter((f) => { const src = readFileSync(join(ROOT, f), 'utf8'); return src.split('\n').some((line) => /style=\{\{[^}]*(?:color|background)/.test(line) && /className=[^>]*\bdark:(?:text|bg|border)-/.test(line)); }); expect(offenders).toEqual([]); }); it('فایل‌های پاک‌شده hex ثابت ندارند', () => { const offenders = files .filter((f) => !INTENTIONAL.includes(f)) .filter((f) => HEX.test(strip(readFileSync(join(ROOT, f), 'utf8')))); expect(offenders).toEqual([]); }); it('فایل‌های پاک‌شده کلاس پالت Tailwind ندارند', () => { const offenders = files .filter((f) => !INTENTIONAL.includes(f)) .filter((f) => PALETTE_UTIL.test(strip(readFileSync(join(ROOT, f), 'utf8')))); expect(offenders).toEqual([]); }); /** * `text-primary-600` و امثالش هیچ رنگی تولید نمی‌کنند: نه در پالت پیش‌فرض Tailwind * هستند و نه در `@theme` تعریف شده‌اند — یعنی کلاسِ مرده در هر دو تم. */ it('کلاس رنگی با مقیاس تعریف‌نشده ندارد', () => { const theme = readFileSync(join(ROOT, 'styles.css'), 'utf8'); const declared = new Set( [...theme.matchAll(/--color-([a-z]+)-\d{2,3}\s*:/g)].map((m) => m[1]), ); const utilities = new RegExp( String.raw`\b(?:bg|text|border|ring|divide|from|to|via|outline|accent|caret|fill|stroke|placeholder|shadow)-([a-z]+)-\d{2,3}\b`, 'g', ); const offenders: string[] = []; for (const f of files) { const src = strip(readFileSync(join(ROOT, f), 'utf8')); for (const m of src.matchAll(utilities)) { if (!TAILWIND_FAMILIES.has(m[1]) && !declared.has(m[1])) offenders.push(`${f}: ${m[0]}`); } } expect(offenders).toEqual([]); }); });