sdlc-hub/web/src/boundary.test.ts

112 lines
5.9 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 { 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);
});
});