112 lines
5.9 KiB
TypeScript
112 lines
5.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
// граница ядра и модулей (design web-modules 10–12): правила проверяются по исходникам
|
||
const raw = (g: Record<string, unknown>): Record<string, string> =>
|
||
Object.fromEntries(Object.entries(g).map(([k, v]) => [k.replace(/^\.\//, "src/"), v as string]));
|
||
|
||
const SRC = raw(import.meta.glob("./**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true }));
|
||
const CSS = raw(import.meta.glob("./**/*.css", { query: "?raw", import: "default", eager: true }));
|
||
|
||
const CORE = /^src\/(shell|ui|api|projects|auth|styles)\//;
|
||
const isTest = (f: string) => /\.test\.tsx?$/.test(f) || f.includes("/test/");
|
||
const coreFiles = Object.keys(SRC).filter((f) => CORE.test(f) && !isTest(f));
|
||
const imports = (text: string): string[] =>
|
||
[...text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)].map((m) => m[1]);
|
||
const moduleOf = (f: string): string | null => /^src\/modules\/([^/]+)\//.exec(f)?.[1] ?? null;
|
||
// куда ведёт импорт относительно файла: нормализованный путь от src/
|
||
const MODULE_DIRS = new Set(Object.keys(SRC).map(moduleOf).filter((m): m is string => !!m));
|
||
function resolve(from: string, spec: string): string {
|
||
const parts = from.split("/").slice(0, -1);
|
||
for (const p of spec.split("/")) {
|
||
if (p === "..") parts.pop(); else if (p !== ".") parts.push(p);
|
||
}
|
||
return parts.join("/");
|
||
}
|
||
|
||
// имена полей и типов модулей, которых в ядре быть не должно (типизирует сам модуль)
|
||
const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count)\b/;
|
||
|
||
// классы, которые «определяет» CSS-текст: только селекторы; значения свойств, атрибуты и :not(...) не считаются
|
||
const classesOf = (css: string): Set<string> => {
|
||
const out = new Set<string>();
|
||
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||
for (const m of noComments.matchAll(/([^{}]+)\{/g)) {
|
||
if (/^\s*@(?!container|media|supports)/.test(m[1])) continue;
|
||
const sel = m[1].replace(/@(container|media|supports)[^{]*$/, "").replace(/\[[^\]]*\]/g, "")
|
||
.replace(/:not\([^)]*\)/g, "").replace(/:(is|where|has)\(/g, " ").replace(/\([^)]*\)/g, "");
|
||
for (const c of sel.matchAll(/\.([a-zA-Z_][\w-]*)/g)) out.add(c[1]);
|
||
}
|
||
return out;
|
||
};
|
||
const cssClashes = (files: Record<string, string>, ownerOf: (f: string) => string | null): string[] => {
|
||
const seen = new Map<string, Map<string, string>>(); // класс -> владелец -> файл
|
||
for (const f of Object.keys(files)) {
|
||
const o = ownerOf(f);
|
||
if (!o) continue;
|
||
for (const c of classesOf(files[f])) {
|
||
if (!seen.has(c)) seen.set(c, new Map());
|
||
if (!seen.get(c)!.has(o)) seen.get(c)!.set(o, f);
|
||
}
|
||
}
|
||
return [...seen].filter(([, m]) => m.size > 1).map(([c, m]) => `.${c}: ${[...m.values()].join(", ")}`);
|
||
};
|
||
|
||
describe("граница ядра и модулей", () => {
|
||
it("core does not import modules", () => {
|
||
const bad: string[] = [];
|
||
for (const f of coreFiles) {
|
||
for (const spec of imports(SRC[f])) {
|
||
if (!spec.startsWith(".")) continue;
|
||
const target = resolve(f, spec);
|
||
if (target.startsWith("src/modules/") && !/^src\/modules\/(index|types)\.ts$/.test(target)) bad.push(`${f} -> ${spec}`);
|
||
}
|
||
}
|
||
expect(bad).toEqual([]);
|
||
});
|
||
|
||
it("module does not import other module", () => {
|
||
const bad: string[] = [];
|
||
for (const f of Object.keys(SRC)) {
|
||
const own = moduleOf(f);
|
||
if (!own || isTest(f)) continue;
|
||
for (const spec of imports(SRC[f])) {
|
||
if (!spec.startsWith(".")) continue;
|
||
const other = moduleOf(resolve(f, spec) + "/");
|
||
if (!other || !MODULE_DIRS.has(other)) continue;
|
||
if (other !== own) bad.push(`${f} -> ${spec}`);
|
||
}
|
||
}
|
||
expect(bad).toEqual([]);
|
||
});
|
||
|
||
it("css classes do not collide", () => {
|
||
expect(cssClashes(CSS, (f) => moduleOf(f) ?? (CORE.test(f) ? "core" : null))).toEqual([]);
|
||
});
|
||
|
||
it("core has no module terms", () => {
|
||
const bad = coreFiles.filter((f) => MODULE_TERMS.test(SRC[f])).map((f) => `${f}: ${SRC[f].match(MODULE_TERMS)![1]}`);
|
||
expect(bad).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// детекторы вынесены в функции, чтобы проверять их на синтетике (M-10)
|
||
describe("детекторы границы", () => {
|
||
it("classesOf: составной селектор даёт класс, значения свойств — нет, :not(.x) не определяет класс", () => {
|
||
expect([...classesOf("div.wiki h1 { margin: 0.5em }")]).toEqual(["wiki"]);
|
||
expect([...classesOf(".a:not(.b) .c[data-x='.d'] { top: 1.5px }")].sort()).toEqual(["a", "c"]);
|
||
expect([...classesOf("@media (min-width: 600px) { .m:is(.n) { color: red } } /* .skip */")].sort()).toEqual(["m", "n"]);
|
||
});
|
||
it("cssClashes: один класс у модуля и ядра — столкновение; у одного владельца — нет", () => {
|
||
const owner = (f: string) => (f.startsWith("core/") ? "core" : f.split("/")[0]);
|
||
expect(cssClashes({ "core/a.css": ".wiki{}", "wiki/b.css": "div.wiki h1{}" }, owner)).toEqual([".wiki: core/a.css, wiki/b.css"]);
|
||
expect(cssClashes({ "wiki/a.css": ".x{}", "wiki/b.css": ".x{}", "jira/c.css": ".y{}" }, owner)).toEqual([]);
|
||
});
|
||
it("MODULE_TERMS ловит поля модулей в ядре (jira_synced_at, board_type), но не слова из обычного текста", () => {
|
||
expect(MODULE_TERMS.test("jira_synced_at: string | null")).toBe(true);
|
||
expect(MODULE_TERMS.test("p.wiki_root")).toBe(true);
|
||
expect(MODULE_TERMS.test("board_type")).toBe(true);
|
||
expect(MODULE_TERMS.test("fact_status")).toBe(true);
|
||
expect(MODULE_TERMS.test("метка задач в Jira")).toBe(false);
|
||
});
|
||
});
|