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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { ERR_CODE, API_CODE } from "../../apps/frontend/src/utils/api-error";
|
||||
import {
|
||||
FLAMY_NAME,
|
||||
FLAMY_URL,
|
||||
} from "../../apps/frontend/src/constants/branding";
|
||||
import ru from "../../apps/frontend/src/lang/package/ru";
|
||||
import en from "../../apps/frontend/src/lang/package/en";
|
||||
|
||||
/**
|
||||
* Контракты панели, которые нельзя проверить ни типами, ни сборкой bundle.
|
||||
*
|
||||
* Все три жили на честном слове: словари локализации расходились молча,
|
||||
* коды ошибок существовали в двух местах без связи между ними, а адрес
|
||||
* атрибуции ничто не удерживало от расползания по шаблонам.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
|
||||
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...sourceFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (/\.(ts|vue|scss|js)$/i.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Коды причин, объявленные сервером в constant.ErrCode*. */
|
||||
function serverErrorCodes(): Set<string> {
|
||||
const source = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
|
||||
"utf8"
|
||||
);
|
||||
const codes = new Set<string>();
|
||||
for (const match of source.matchAll(
|
||||
/ErrCode[A-Za-z]+\s+string\s*=\s*"([^"]+)"/g
|
||||
)) {
|
||||
codes.add(match[1]);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
function leafKeys(value: unknown, prefix = ""): string[] {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return [prefix];
|
||||
}
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, v]) =>
|
||||
leafKeys(v, prefix ? `${prefix}.${key}` : key)
|
||||
);
|
||||
}
|
||||
|
||||
describe("локализация", () => {
|
||||
// Ключ, забытый в одном словаре, не ломает ни типы, ни сборку: vue-i18n
|
||||
// молча отдаёт сам ключ, и оператор видит `error.code.min_length` вместо
|
||||
// фразы. Единственное место, где это может быть замечено заранее, — здесь.
|
||||
test("наборы ключей ru и en совпадают", () => {
|
||||
const ruKeys = new Set(leafKeys(ru));
|
||||
const enKeys = new Set(leafKeys(en));
|
||||
|
||||
expect([...ruKeys].filter((key) => !enKeys.has(key)).sort()).toEqual([]);
|
||||
expect([...enKeys].filter((key) => !ruKeys.has(key)).sort()).toEqual([]);
|
||||
});
|
||||
|
||||
// Панель выбирает фразу по коду ответа. Код без фразы доезжает до оператора
|
||||
// серверным сообщением — это работает, но на языке сервера, а не панели.
|
||||
test("у каждого известного кода ошибки есть фраза в обоих словарях", () => {
|
||||
const missing: string[] = [];
|
||||
for (const code of Object.values(ERR_CODE)) {
|
||||
for (const [locale, dictionary] of [
|
||||
["ru", ru],
|
||||
["en", en],
|
||||
] as const) {
|
||||
const messages = (dictionary as any).error?.code ?? {};
|
||||
if (typeof messages[code] !== "string") {
|
||||
missing.push(`${locale}: error.code.${code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test("коды ответа совпадают с серверными константами", () => {
|
||||
const codeSource = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "model", "constant", "code.go"),
|
||||
"utf8"
|
||||
);
|
||||
const declared = new Map<string, number>();
|
||||
for (const match of codeSource.matchAll(
|
||||
/(Code[A-Za-z]+)\s+int\s*=\s*(\d+)/g
|
||||
)) {
|
||||
declared.set(match[1], Number(match[2]));
|
||||
}
|
||||
|
||||
expect(declared.get("CodeSuccess")).toBe(API_CODE.success);
|
||||
expect(declared.get("CodeSysError")).toBe(API_CODE.systemError);
|
||||
expect(declared.get("CodeInvalidError")).toBe(API_CODE.validationFailed);
|
||||
expect(declared.get("CodeUnauthorizedError")).toBe(API_CODE.unauthorized);
|
||||
expect(declared.get("CodeForbiddenError")).toBe(API_CODE.forbidden);
|
||||
});
|
||||
|
||||
// Коды причин объявлены на сервере; панель обязана знать их под теми же
|
||||
// именами. Расхождение здесь тихо отключает локализацию для целого класса
|
||||
// отказов.
|
||||
//
|
||||
// Проверяются ОБА направления. Одного мало: направление «панель → сервер»
|
||||
// ловит выдуманный код, но не ловит серверный код, о котором панель не
|
||||
// знает, — а именно так добавляется новое правило. Ровно это здесь и
|
||||
// случилось: коды min_length/max_length появились на сервере после того, как
|
||||
// карта кодов панели была написана, и проверка в одну сторону молчала.
|
||||
test("коды причин совпадают с серверными константами", () => {
|
||||
const serverCodes = serverErrorCodes();
|
||||
|
||||
expect(serverCodes.size).toBeGreaterThan(0);
|
||||
|
||||
const unknownToServer = Object.values(ERR_CODE).filter(
|
||||
(code) => !serverCodes.has(code)
|
||||
);
|
||||
expect(unknownToServer).toEqual([]);
|
||||
|
||||
const known = new Set<string>(Object.values(ERR_CODE));
|
||||
expect([...serverCodes].filter((code) => !known.has(code)).sort()).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// Серверный код без фразы доезжает до оператора сообщением сервера — это
|
||||
// работает, но на языке сервера, а не панели.
|
||||
test("у каждого серверного кода есть фраза в обоих словарях", () => {
|
||||
const missing: string[] = [];
|
||||
for (const code of serverErrorCodes()) {
|
||||
for (const [locale, dictionary] of [
|
||||
["ru", ru],
|
||||
["en", en],
|
||||
] as const) {
|
||||
const messages = (dictionary as any).error?.code ?? {};
|
||||
if (typeof messages[code] !== "string") {
|
||||
missing.push(`${locale}: error.code.${code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("атрибуция Flamy", () => {
|
||||
test("адрес объявлен один раз и ведёт на flamy.studio", () => {
|
||||
expect(FLAMY_URL).toBe("https://flamy.studio");
|
||||
expect(FLAMY_NAME).toBe("Flamy");
|
||||
|
||||
const carriers = sourceFiles(FRONTEND_SRC).filter((file) =>
|
||||
fs.readFileSync(file, "utf8").includes("flamy.studio")
|
||||
);
|
||||
expect(carriers.map((file) => path.relative(REPO_ROOT, file))).toEqual([
|
||||
path.join("apps", "frontend", "src", "constants", "branding.ts"),
|
||||
]);
|
||||
});
|
||||
|
||||
// Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
|
||||
// подпись разработчика. Проверяются все каналы, через которые значение
|
||||
// могло бы стать настраиваемым.
|
||||
test("адрес не является операторской настройкой", () => {
|
||||
const operatorSurfaces = [
|
||||
"package/config/hy2xs.env",
|
||||
"package/templates/env/post-install.env.tpl",
|
||||
"apps/model/constant/config.go",
|
||||
];
|
||||
for (const relative of operatorSurfaces) {
|
||||
const source = fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
|
||||
expect(source.toLowerCase()).not.toContain("flamy");
|
||||
}
|
||||
});
|
||||
|
||||
test("футер отрисован в боковом меню и учтён в его высоте", () => {
|
||||
const sidebar = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "index.vue"),
|
||||
"utf8"
|
||||
);
|
||||
expect(sidebar).toContain("<Footer");
|
||||
|
||||
// Высота области прокрутки обязана вычитать высоту футера, иначе пункты
|
||||
// меню наезжают на подпись при длинном списке.
|
||||
const styles = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "styles", "sidebar.scss"),
|
||||
"utf8"
|
||||
);
|
||||
expect(styles).toContain("$sidebarFooterHeight");
|
||||
|
||||
const variables = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "styles", "variables.scss"),
|
||||
"utf8"
|
||||
);
|
||||
expect(variables).toMatch(/\$sidebarFooterHeight:\s*\d+px/);
|
||||
});
|
||||
|
||||
test("внешняя ссылка открывается безопасно", () => {
|
||||
const footer = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const anchors = [...footer.matchAll(/<a\b[\s\S]*?>/g)].map((m) => m[0]);
|
||||
expect(anchors.length).toBeGreaterThan(0);
|
||||
for (const anchor of anchors) {
|
||||
expect(anchor).toContain('target="_blank"');
|
||||
expect(anchor).toContain('rel="noopener noreferrer"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("секрет пира", () => {
|
||||
// Прямой запрет из требований: генерация секрета принадлежит серверу.
|
||||
// Панель, подставляющая значение в пустое поле, выполняла бы обещание
|
||||
// «сгенерируем автоматически» ровно для одной двери из четырёх.
|
||||
test("панель не генерирует секрет сама", () => {
|
||||
const form = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
|
||||
"utf8"
|
||||
);
|
||||
expect(form).not.toMatch(/crypto\.getRandomValues/);
|
||||
expect(form).not.toMatch(/Math\.random/);
|
||||
// Подстановка значения в пустой секрет перед отправкой — тот же обход
|
||||
// другими средствами.
|
||||
expect(form).not.toMatch(/secret\s*=\s*dataForm\.secret\s*\|\|\s*["'`]/);
|
||||
});
|
||||
|
||||
test("пустой секрет уезжает на сервер как есть", () => {
|
||||
const form = fs.readFileSync(
|
||||
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
|
||||
"utf8"
|
||||
);
|
||||
expect(form).toContain("await savePeerApi(dataForm)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
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*:/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user