Files
houseplan-card/test/single-source-numbers.test.mjs
T
2026-08-28 05:40:28 +00:00

126 lines
6.5 KiB
JavaScript
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 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}`);
}
});