mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
126 lines
6.5 KiB
JavaScript
126 lines
6.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
|
||
/**
|
||
* Правило одного источника для числа, видимого пользователю (#254, ревизия
|
||
* 2026-08-23).
|
||
*
|
||
* Три дефекта за месяц имели одну причину: одно и то же число считалось в двух
|
||
* местах и расходилось.
|
||
*
|
||
* #234 — превью «резинки» показывало 12 см, запись сохраняла 24;
|
||
* #233 — подпись ресайза мерила по осевым линиям, площадь рядом — по полу;
|
||
* #234 (как нашли) — подсветка инструмента «Толщина» врала согласованно
|
||
* со записью, потому что обе брали толщину по-своему.
|
||
*
|
||
* Полностью механизировать правило нельзя: «одно и то же число» — смысловое
|
||
* понятие. Механизируется его самая дорогая часть — форматирование величин.
|
||
* Пока строку с единицей измерения собирает ровно один форматтер на величину,
|
||
* разойтись двум подписям невозможно; как только рядом появляется второе место,
|
||
* склеивающее число с «м» или «м²», расхождение становится вопросом времени.
|
||
*/
|
||
|
||
const UNIT_FORMATTING = new RegExp(
|
||
// Число, попадающее в строку: либо интерполяция, либо конкатенация после
|
||
// toFixed. Дальше — единица измерения, без требования разделителя: в
|
||
// конкатенации кавычку уже съел предыдущий кусок.
|
||
'(\\$\\{[^{}]*\\}|toFixed\\([0-9]\\)\\s*\\)?\\s*\\+\\s*[\'`])'
|
||
// `\\b` в JS считает границу по ASCII, поэтому после кириллической буквы он
|
||
// не срабатывает: для «см» и «м» нужен явный отрицательный просмотр.
|
||
+ '\\s*(m²|ft²|м²|m\\b|cm\\b|см(?![а-яё])|м(?![а-яё])|′|″)',
|
||
'g',
|
||
);
|
||
|
||
/**
|
||
* Единственные места, которым позволено склеивать число с единицей измерения.
|
||
* Окно намеренно узкое: разрешается сам форматтер, а не файл целиком —
|
||
* иначе правило растворится в первом же большом модуле.
|
||
*/
|
||
const FORMATTERS = [
|
||
{ file: 'src/logic.ts', anchor: 'export function formatLength(', lines: 12 },
|
||
{ file: 'src/area-format.ts', anchor: 'export function formatArea(', lines: 8 },
|
||
];
|
||
|
||
const sourceFiles = (root) => {
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const name of readdirSync(dir).sort()) {
|
||
const path = join(dir, name);
|
||
if (statSync(path).isDirectory()) walk(path);
|
||
else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(path);
|
||
}
|
||
};
|
||
walk(join(root, 'src'));
|
||
return out;
|
||
};
|
||
|
||
const allowedWindows = (root) => FORMATTERS.map((formatter) => {
|
||
const text = readFileSync(join(root, formatter.file), 'utf8');
|
||
const index = text.indexOf(formatter.anchor);
|
||
assert.ok(index >= 0, `${formatter.file}: не найден форматтер ${formatter.anchor}`);
|
||
const from = text.slice(0, index).split('\n').length;
|
||
return { file: join(root, formatter.file), from, to: from + formatter.lines };
|
||
});
|
||
|
||
export function unitFormattingSites(root = repoRoot) {
|
||
const windows = allowedWindows(root);
|
||
const sites = [];
|
||
for (const file of sourceFiles(root)) {
|
||
const text = readFileSync(file, 'utf8');
|
||
UNIT_FORMATTING.lastIndex = 0;
|
||
for (const match of text.matchAll(UNIT_FORMATTING)) {
|
||
const line = text.slice(0, match.index).split('\n').length;
|
||
const allowed = windows.some((window) =>
|
||
window.file === file && line >= window.from && line <= window.to);
|
||
if (!allowed) {
|
||
sites.push(`${file.slice(root.length)}:${line} ${JSON.stringify(match[0].trim())}`);
|
||
}
|
||
}
|
||
}
|
||
return sites;
|
||
}
|
||
|
||
test('строку с единицей измерения собирает только канонический форматтер (#254)', () => {
|
||
assert.deepEqual(unitFormattingSites(), [],
|
||
'число, видимое пользователю, обязано иметь один источник: расхождение подписей '
|
||
+ 'уже стоило продукту #233 и #234');
|
||
});
|
||
|
||
test('детектор действительно ловит самодельное форматирование (#254)', () => {
|
||
// Проверка на проверку: правило, которое не может сработать, бесполезно.
|
||
// Прогоняем детектор по временной копии дерева с одной рукодельной строкой.
|
||
const samples = [
|
||
'const label = `${(cm / 100).toFixed(2)} m`;',
|
||
"const label = (cm / 100).toFixed(2) + ' м';",
|
||
'const label = `${value} м²`;',
|
||
];
|
||
for (const sample of samples) {
|
||
UNIT_FORMATTING.lastIndex = 0;
|
||
assert.ok(UNIT_FORMATTING.test(sample), `детектор не увидел: ${sample}`);
|
||
}
|
||
const clean = [
|
||
'const label = this._fmtLen(a, b);',
|
||
'const cm = wallCmToUnits(50, cellCm, gridPitch);',
|
||
'const text = formatArea(area, imperial);',
|
||
];
|
||
for (const sample of clean) {
|
||
UNIT_FORMATTING.lastIndex = 0;
|
||
assert.ok(!UNIT_FORMATTING.test(sample), `ложное срабатывание: ${sample}`);
|
||
}
|
||
});
|
||
|
||
test('оба канонических форматтера на месте и единственны (#254)', () => {
|
||
// Если форматтер переименуют или размножат, правило должно об этом сказать,
|
||
// а не тихо разрешить всё.
|
||
for (const formatter of FORMATTERS) {
|
||
const text = readFileSync(join(repoRoot, formatter.file), 'utf8');
|
||
const count = text.split(formatter.anchor).length - 1;
|
||
assert.equal(count, 1, `${formatter.file}: ожидался ровно один ${formatter.anchor}`);
|
||
}
|
||
});
|