import { describe, expect, test } from "bun:test"; import fs from "node:fs"; import path from "node:path"; import { MULTICOLOR_ICONS, SYMBOL_PREFIX, findIconContractViolations, iconName, toSymbol, } from "../../apps/frontend/src/components/SvgIcon/symbol"; /** * Контракт спрайта локальных иконок. * * Почему тест лежит здесь, а не в apps/frontend. Во frontend нет тестового * рантайма, и заводить его ради этой проверки не нужно: vitest с jsdom не * вычисляет `currentColor` и визуальной корректности всё равно не доказал бы, * зато привёл бы в граф `pnpm audit` (порог high по ВСЕМУ lock-файлу) сотню * транзитивных зависимостей. Проверяемый модуль `SvgIcon/symbol.ts` чистый, * поэтому его исполняет уже закреплённый в versions.env Bun — тот же, которым * проверяется оркестратор. * * Тест не претендует на доказательство визуальной корректности: цвет на * экране проверяется человеком и фиксируется в отчёте приёмки. Здесь * закрепляется то, что машина проверить может, — что ни один ассет не задаёт * цвет мимо `currentColor` и что обе половины контракта (ассет и CSS) на * месте. */ const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src"); const ICONS_DIR = path.join(FRONTEND_SRC, "assets", "icons"); function iconFiles(): string[] { return fs .readdirSync(ICONS_DIR) .filter((file) => file.toLowerCase().endsWith(".svg")) .sort(); } function readIcon(file: string): string { return fs.readFileSync(path.join(ICONS_DIR, file), "utf8"); } function readSource(relative: string): string { return fs.readFileSync(path.join(REPO_ROOT, relative), "utf8"); } describe("ассеты иконок", () => { test("каталог иконок не пуст", () => { expect(iconFiles().length).toBeGreaterThan(0); }); // Регрессия. Восемь ассетов несли литеральный `fill="#000000"` на : // атрибут представления перебивает унаследованное CSS-свойство, поэтому // объявленный в двух местах `fill: currentcolor` не действовал, и все семь // иконок бокового меню рисовались чёрным по фону #181818. test("каждый ассет соответствует контракту спрайта", () => { const violations: string[] = []; for (const file of iconFiles()) { violations.push( ...findIconContractViolations(readIcon(file), iconName(file)) ); } expect(violations).toEqual([]); }); test("монохромные ассеты не содержат литеральных цветов", () => { const offenders: string[] = []; for (const file of iconFiles()) { const name = iconName(file); if (MULTICOLOR_ICONS.has(name)) { continue; } if (/#[0-9a-f]{3,8}\b/i.test(readIcon(file))) { offenders.push(name); } } expect(offenders).toEqual([]); }); // Список многоцветных — закрытое решение, а не свалка. Устаревшая запись в // нём молча снимала бы проверку цвета с иконки, которой уже нет. test("в списке многоцветных нет записей без ассета", () => { const present = new Set(iconFiles().map(iconName)); for (const name of MULTICOLOR_ICONS) { expect(present.has(name)).toBe(true); } }); // Многоцветный ассет обязан сохранять СВОИ цвета: общая нормализация к нему // не применяется, и это утверждение проверяется на настоящем файле. test("многоцветный ассет сохраняет собственную палитру", () => { const download = readIcon("download.svg"); expect(download).toContain('fill="#00C97C"'); expect(findIconContractViolations(download, "download")).toEqual([]); }); }); describe("контракт ассета", () => { const OPEN = ''; test("литеральный fill у монохромной иконки — нарушение", () => { const violations = findIconContractViolations( `${OPEN}`, "mono" ); expect(violations.length).toBe(1); expect(violations[0]).toContain("мимо currentColor"); }); test("тот же ассет в списке многоцветных нарушением не является", () => { expect( findIconContractViolations( `${OPEN}`, "download" ) ).toEqual([]); }); test("currentColor, none и transparent разрешены", () => { expect( findIconContractViolations( `${OPEN}` + ``, "mono" ) ).toEqual([]); }); test("цвет в инлайновом style — нарушение", () => { const violations = findIconContractViolations( `${OPEN}`, "mono" ); expect(violations.length).toBe(1); expect(violations[0]).toContain("инлайновый style"); }); test("непустой `, "mono" ); expect(violations.length).toBe(1); expect(violations[0]).toContain("` + ``, "mono" ) ).toEqual([]); }); test("растровое — нарушение", () => { const violations = findIconContractViolations( `${OPEN}`, "mono" ); expect(violations.length).toBe(1); expect(violations[0]).toContain(""); }); test("ассет без системы координат — нарушение", () => { const violations = findIconContractViolations( '', "mono" ); expect(violations.length).toBe(1); expect(violations[0]).toContain("viewBox"); }); test("отсутствие корневого — нарушение", () => { expect(findIconContractViolations("не svg", "mono").length).toBe(1); }); }); describe("сборка symbol", () => { test("symbol получает id с общим префиксом и viewBox", () => { const symbol = toSymbol( '', "user" ); expect(symbol.startsWith(`'); }); test("viewBox синтезируется из width/height, когда не объявлен", () => { expect( toSymbol('', "eye") ).toContain('viewBox="0 0 128 128"'); }); test("пролог, DOCTYPE и комментарии не уезжают в документ", () => { const symbol = toSymbol( '' + '', "report" ); expect(symbol).not.toContain(" { for (const file of iconFiles()) { const symbol = toSymbol(readIcon(file), iconName(file)); expect(symbol).toContain(`id="${SYMBOL_PREFIX}-${iconName(file)}"`); expect(symbol).toContain("viewBox="); } }); }); describe("иконки, которые запрашивает приложение", () => { // Имя иконки вычисляется в рантайме, поэтому опечатка в meta.icon или в // icon-class не ломает ни типы, ни сборку: `` просто не находит symbol // и рисует пустоту. test("каждое запрошенное имя существует как ассет", () => { const present = new Set(iconFiles().map(iconName)); const requested = new Set(); const router = readSource("apps/frontend/src/router/index.ts"); for (const match of router.matchAll(/\bicon:\s*"([^"]+)"/g)) { requested.add(match[1]); } const vueFiles = [ "apps/frontend/src/views/login/index.vue", "apps/frontend/src/layout/components/Navbar.vue", "apps/frontend/src/components/LangSelect/index.vue", ]; for (const file of vueFiles) { const source = readSource(file); // Литеральное имя: `icon-class="user"`. Двоеточие впереди исключается — // `:icon-class` содержит выражение, а не имя, и разбирается ниже. for (const match of source.matchAll(/(? !present.has(name))).toEqual([]); }); }); describe("вторая половина контракта — CSS", () => { // Ассет без литерального цвета сам по себе цвета не даёт: он его // НАСЛЕДУЕТ. Если правило `fill: currentcolor` исчезнет, иконки станут // чёрными по инициальному значению SVG, и ни одна проверка выше этого не // заметит. test("SvgIcon объявляет fill: currentcolor", () => { expect( readSource("apps/frontend/src/components/SvgIcon/index.vue") ).toMatch(/fill:\s*currentcolor/i); }); test("боковое меню не задаёт иконкам собственный цвет", () => { expect(readSource("apps/frontend/src/styles/sidebar.scss")).toMatch( /\.svg-icon\s*\{[^}]*fill:\s*currentcolor/i ); }); // Прямой запрет из требований к исправлению: маскировать дефект pipeline // фильтром или перекрашивать иконку по её имени нельзя. test("нет CSS-фильтров и правил на имя иконки", () => { const styleFiles = fs .readdirSync(path.join(FRONTEND_SRC, "styles")) .map((file) => `apps/frontend/src/styles/${file}`); for (const file of [ ...styleFiles, "apps/frontend/src/components/SvgIcon/index.vue", ]) { const source = readSource(file); expect(source).not.toMatch(/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i); expect(source).not.toMatch(/\[icon-class[~^*$|]?=/i); } }); // Проп цвета убран сознательно: он приглашал чинить цвет точечно, в обход // общего контракта. // // Проверяется СИНТАКСИЧЕСКАЯ ФОРМА, а не подстрока. Комментарий в самом // компоненте обязан называть убранное по имени — иначе он бесполезен, — и // скан по тексту падал бы ровно на объяснении выполненной работы. test("SvgIcon не принимает цвет параметром", () => { const component = readSource( "apps/frontend/src/components/SvgIcon/index.vue" ); const useTag = component.match(/]*>/); expect(useTag).not.toBeNull(); expect(useTag![0]).not.toMatch(/\bfill\s*=/); const propsBlock = component.match(/defineProps\(\{[\s\S]*?\n\}\)/); expect(propsBlock).not.toBeNull(); expect(propsBlock![0]).not.toMatch(/\bcolor\s*:/); }); });