textmachine/frontend/src/cssModules.test.ts

78 lines
3.5 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 { basename, dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
// Vite типизирует модуль стилей как { [key: string]: string }, поэтому опечатка в styles.чтоТо
// проходит и тайпчек, и линт, и превращается в className="undefined" — тихо и без следа.
// Поймано на живом коде витрины, поэтому проверяется тестом, а не глазами.
const modules = import.meta.glob('./**/*.module.css', {
query: '?raw',
eager: true,
import: 'default',
});
const sources = import.meta.glob('./**/*.{ts,tsx}', {
query: '?raw',
eager: true,
import: 'default',
});
// url(...) и строковые литералы выкидываются до поиска классов: иначе расширение файла из
// url(./x.woff2) приезжает в «объявленные классы» и прикрывает собой опечатку (Ф-10).
const classesOf = (css: string) =>
new Set(
css
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/url\([^)]*\)/g, '')
.replace(/"[^"]*"|'[^']*'/g, '')
.match(/\.[a-zA-Z][\w-]*/g)
?.map((name) => name.slice(1)),
);
// Пары строятся ПО ИМПОРТАМ, а не по совпадению имён файлов: компонент, импортирующий соседний
// модуль, выпадал из проверки молча, и таких пар в S2 сразу несколько (Ф-10).
const importsOf = (source: string) =>
[...source.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+\.module\.css)['"]/g)].map((match) => ({
binding: match[1] ?? '',
specifier: match[2] ?? '',
}));
// Три формы обращения к модулю: styles.name · styles['name'] · const { name } = styles.
// Деструктуризацию прежняя проверка не видела вовсе.
const usedIn = (source: string, binding: string) => {
const direct = [
...source.matchAll(new RegExp(String.raw`\b${binding}(?:\.(\w+)|\[['"]([\w-]+)['"]\])`, 'g')),
].map((match) => match[1] ?? match[2] ?? '');
const destructured = [
...source.matchAll(new RegExp(String.raw`\{([^{}]*)\}\s*=\s*${binding}\b`, 'g')),
]
.flatMap((match) => (match[1] ?? '').split(','))
.map((part) => part.split(':')[0]?.trim() ?? '')
.filter((name) => /^\w+$/.test(name));
return [...direct, ...destructured];
};
const pairs = Object.entries(sources).flatMap(([file, source]) =>
importsOf(source).map(({ binding, specifier }) => ({
file,
module: `./${join(dirname(file), specifier)}`,
binding,
})),
);
describe('CSS Modules', () => {
it.each(pairs.map((pair) => [pair.file, pair.module, pair.binding] as const))(
'%s не ссылается на несуществующий класс из %s',
(file, module, binding) => {
expect(modules[module], `модуль ${module} не найден`).toBeDefined();
const defined = classesOf(modules[module] as string);
const missing = usedIn(sources[file] as string, binding).filter((name) => !defined.has(name));
expect(missing, `нет в ${basename(module)}`).toEqual([]);
},
);
it('каждый модуль стилей кем-то импортирован', () => {
const imported = new Set(pairs.map((pair) => pair.module));
const orphans = Object.keys(modules).filter((module) => !imported.has(module));
expect(orphans).toEqual([]);
});
});