Files
HY2XS_flamy/tools/test/frontend-sprite.test.ts
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

313 lines
13 KiB
TypeScript
Raw Permalink 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, 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"` на <path>:
// атрибут представления перебивает унаследованное 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 = '<svg viewBox="0 0 24 24">';
test("литеральный fill у монохромной иконки — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("мимо currentColor");
});
test("тот же ассет в списке многоцветных нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"download"
)
).toEqual([]);
});
test("currentColor, none и transparent разрешены", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="none" stroke="currentColor" d="M0 0"/>` +
`<rect fill="transparent"/></svg>`,
"mono"
)
).toEqual([]);
});
test("цвет в инлайновом style — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path style="fill:#191919;opacity:.5" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("инлайновый style");
});
test("непустой <style> внутри ассета — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<style>.a{fill:red}</style><path d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<style>");
});
test("пустой <style> от редактора нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<defs><style type="text/css"></style></defs>` +
`<path d="M0 0"/></svg>`,
"mono"
)
).toEqual([]);
});
test("растровое <image> — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<image href="data:image/png;base64,AA"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<image>");
});
test("ассет без системы координат — нарушение", () => {
const violations = findIconContractViolations(
'<svg><path d="M0 0"/></svg>',
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("viewBox");
});
test("отсутствие корневого <svg> — нарушение", () => {
expect(findIconContractViolations("не svg", "mono").length).toBe(1);
});
});
describe("сборка symbol", () => {
test("symbol получает id с общим префиксом и viewBox", () => {
const symbol = toSymbol(
'<svg viewBox="0 0 32 32"><path d="M0 0"/></svg>',
"user"
);
expect(symbol.startsWith(`<symbol id="${SYMBOL_PREFIX}-user"`)).toBe(true);
expect(symbol).toContain('viewBox="0 0 32 32"');
expect(symbol).toContain('<path d="M0 0"/>');
});
test("viewBox синтезируется из width/height, когда не объявлен", () => {
expect(
toSymbol('<svg width="128" height="128"><path/></svg>', "eye")
).toContain('viewBox="0 0 128 128"');
});
test("пролог, DOCTYPE и комментарии не уезжают в документ", () => {
const symbol = toSymbol(
'<?xml version="1.0"?><!DOCTYPE svg><!-- заметка -->' +
'<svg viewBox="0 0 24 24"><path/></svg>',
"report"
);
expect(symbol).not.toContain("<?xml");
expect(symbol).not.toContain("<!DOCTYPE");
expect(symbol).not.toContain("заметка");
});
test("каждый ассет даёт symbol с системой координат", () => {
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 не ломает ни типы, ни сборку: `<use>` просто не находит symbol
// и рисует пустоту.
test("каждое запрошенное имя существует как ассет", () => {
const present = new Set(iconFiles().map(iconName));
const requested = new Set<string>();
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(/(?<![:\w-])icon-class="([^"]+)"/g)) {
requested.add(match[1]);
}
// Тернарный выбор имени: :icon-class="a ? 'x' : 'y'".
for (const match of source.matchAll(
/:icon-class="[^"]*?'([^']+)'\s*:\s*'([^']+)'/g
)) {
requested.add(match[1]);
requested.add(match[2]);
}
}
expect(requested.size).toBeGreaterThan(0);
expect([...requested].filter((name) => !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(/<use\b[^>]*>/);
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*:/);
});
});