test: one number, one source — as a checkable rule

Issue: #254
User-Visible: no
This commit is contained in:
Matysh
2026-08-23 09:49:22 +03:00
parent 59be468506
commit dc3c083352
3 changed files with 149 additions and 0 deletions
+11
View File
@@ -576,6 +576,17 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Одно число — один источник.** Любая величина, которую пользователь видит
дважды — превью против записи, подпись против площади, подсветка инструмента
против сохранённого значения, — обязана считаться в одном месте. Три дефекта
подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила
24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ,
которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер
отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него
источник. Механическая часть правила закреплена тестом
`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает
только канонический форматтер; смысловая часть остаётся за ревью.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
+13
View File
@@ -165,6 +165,19 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'unit-formatting-escapes-the-formatter',
guard: 'node --test --test-name-pattern="детектор действительно ловит" '
+ 'test/single-source-numbers.test.mjs',
because: 'одно число, видимое дважды, обязано иметь один источник: расхождение превью и '
+ 'записи стоило продукту #234, а расхождение подписи и площади — #233. Детектор без '
+ 'способности сработать — это разрешение собирать подписи где угодно',
patches: [{
file: 'test/single-source-numbers.test.mjs',
find: " + '\\\\s*(m²|ft²|м²|m\\\\b|cm\\\\b|см(?![а-яё])|м(?![а-яё])|′|″)',",
replace: " + '\\\\s*(ЗАГЛУШКА)',",
}],
},
{
id: 'invariant-loses-wall-record',
guard: 'node --test --test-name-pattern="исчезнувшая запись толщины" '
+125
View File
@@ -0,0 +1,125 @@
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/resize.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}`);
}
});