ci: derive the browser-smoke shortlist from the diff

Issue: #241
User-Visible: no
This commit is contained in:
Matysh
2026-08-22 18:32:52 +03:00
parent e943e2f1b2
commit c0a4fa29ee
7 changed files with 902 additions and 0 deletions
+14
View File
@@ -122,6 +122,20 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'smoke-select-drops-registered-link',
guard: 'node --test --test-name-pattern="держится на зарегистрированной связи" '
+ 'test/smoke-select.test.mjs',
because: 'связь «изменённый контракт → смок, который его не называет» доказать поиском '
+ 'нельзя, и держится она только на реестре: без записи выборка по диффу #234 снова '
+ 'промолчит о smoke_wall_thickness_transition — том самом классе смоков, на котором '
+ '#234 потерял регресс (#241)',
patches: [{
file: 'scripts/smoke-links.mjs',
find: " symbols: ['chainSegmentCms', 'wallChainSegments', '_draftSegmentCms', '_closingWallCm'],",
replace: ' symbols: [],',
}],
},
{
id: 'inner-span-ignores-neighbour-thickness',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+59
View File
@@ -0,0 +1,59 @@
/**
* Явные связи «символ продуктового кода → browser-smoke» (#241).
*
* Зачем реестр вообще нужен. Смоки не импортируют исходники: они грузят
* собранный бандл и работают через DOM и приватные поля в `page.evaluate`.
* Поэтому граф импортов их не свяжет, а совпадение идентификаторов находит
* только те смоки, которые называют затронутый символ своим именем. Смок,
* проверяющий тот же контракт через другое поле, не найдётся никаким поиском —
* и ровно на таком смоке #234 потерял регресс.
*
* Что сюда попадает. Связь, которую нельзя доказать текстом: смок проверяет
* следствие контракта, не называя ни одного изменённого символа. Каждая запись
* обязана объяснять, ЧТО именно проверяет смок — иначе реестр превращается в
* список суеверий, который никто не решается почистить.
*
* Чего сюда писать НЕ надо. Связи, которые и так находятся по имени: запись
* рядом с прямым совпадением делает вывод selector'а неотличимым от «нашли
* потому что записали», и тест начинает проходить по неверной причине.
*/
/**
* @typedef {object} SmokeLink
* @property {string[]} symbols Изменённые символы, включающие связь.
* @property {string[]} smokes Файлы в `demo/`, без пути.
* @property {string} because Что смок проверяет и почему поиском не найдётся.
*/
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
// #234: единый резолвер толщины отрезка цепочки. Смок перехода между
// толщинами не называет ни `chainSegmentCms`, ни `_draftSegmentCms` — он
// рисует стены инструментом и проверяет, что кладка на стыке толщин
// остаётся связной. Общих с диффом символов у него ровно два, `_cellCm` и
// `_gridPitch`, и оба слишком широкие, чтобы что-то доказывать.
symbols: ['chainSegmentCms', 'wallChainSegments', '_draftSegmentCms', '_closingWallCm'],
smokes: ['smoke_wall_thickness_transition.mjs'],
because: 'переход между двумя толщинами в одной цепочке: резолвер толщины '
+ 'отрезка решает, где кладка меняет глубину, а смок проверяет кладку, '
+ 'а не толщину, и ни одного имени из резолвера не называет',
},
];
/** Смоки, связанные с изменёнными символами через реестр. */
export function registeredSmokes(changedSymbols) {
const changed = new Set(changedSymbols);
const out = new Map();
for (const link of SMOKE_LINKS) {
const hit = link.symbols.filter((symbol) => changed.has(symbol)).sort();
if (!hit.length) continue;
for (const smoke of link.smokes) {
const entry = out.get(smoke) || { smoke, symbols: [], because: [] };
entry.symbols = [...new Set([...entry.symbols, ...hit])].sort();
if (!entry.because.includes(link.because)) entry.because.push(link.because);
out.set(smoke, entry);
}
}
return [...out.values()].sort((a, b) => a.smoke.localeCompare(b.smoke));
}
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env node
/**
* Какие существующие browser-smoke относятся к этому диффу (#241).
*
* node scripts/smoke-select.mjs --base origin/dev --head HEAD
* node scripts/smoke-select.mjs --diff patch.diff (или `-` для stdin)
* node scripts/smoke-select.mjs --base A --head B --json
*
* Инструмент отвечает на один вопрос: какие из уже написанных смоков исполняют
* или проверяют то, что тронул дифф. Он НЕ решает, достаточно ли этих смоков,
* не заменяет AC задачи и не отменяет полный предрелизный прогон.
*
* Почему не граф импортов. Смоки не импортируют `src/**`: они грузят собранный
* бандл и работают через DOM и приватные поля в `page.evaluate`. Единственный
* доказуемый след связи — упоминание символа в тексте смока; всё остальное
* идёт через явный реестр `scripts/smoke-links.mjs`.
*
* Три вида ответа, и они не смешиваются:
* 1. прямое совпадение — смок называет изменённый символ;
* 2. зарегистрированная связь — смок проверяет следствие контракта, не
* называя ни одного изменённого символа (реестр);
* 3. неопределённость — дифф исполняемый, но связь не доказана. Это НЕ
* «проверять нечего»: молчание здесь стоило #234 бета-блокирующего
* регресса, и молчать инструмент не имеет права.
*/
import { spawnSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { registeredSmokes } from './smoke-links.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Символ, который встречается больше чем в этой доле смоков, ничего не
* различает: `_serverCfg` есть в 112 из 167, `_setMode` в 101. Такой символ не
* повод рекомендовать смок — иначе любая правка карточки предлагает всю
* матрицу, а это ровно то, чего задача просила не делать. Порог измерен по
* фактическому распределению, а не выбран на глаз.
*/
export const BROAD_SHARE = 0.2;
/** Файлы, чей дифф способен что-то сломать в браузере. */
const isExecutableFrontend = (file) => file.startsWith('src/')
&& file.endsWith('.ts') && !file.endsWith('.d.ts');
/**
* Объявления, дающие таблицу символов проекта. Смысл таблицы — отсечь общие
* слова: `length`, `value` и `return` встречаются и в диффе, и в смоках, но
* символами продукта не являются.
*/
const DECLARATIONS = [
// Модульная область: объявления без отступа. Локальные переменные внутри
// функций сюда не попадают намеренно — их имена не образуют контракт.
/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
/^(?:export\s+)?(?:abstract\s+)?(?:class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
// Члены класса: либо с явным модификатором, либо с приватным подчёркиванием
// (карточка держит всё внутреннее на `_`).
/^\s+(?:private|protected|public)\s+(?:static\s+|readonly\s+|async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*[(=:<]/gm,
/^\s+(?:static\s+|readonly\s+|async\s+|get\s+|set\s+)*(_[A-Za-z][\w$]*)\s*[(=:<]/gm,
];
/**
* Имя, по которому связь вообще можно приписать. Одиночное строчное слово —
* `floor`, `gap`, `own`, `edit` — это английский, а не идентификатор: такие
* слова встречаются и в диффе, и в половине смоков, и первая же версия этого
* инструмента из-за них рекомендовала 130 смоков из 167. Различают связь
* только `_приватные`, camelCase из двух слов и Прописные.
*/
const isDistinctive = (name) => /^_/.test(name)
|| /[a-z][A-Z]/.test(name) || /^[A-Z]/.test(name);
/**
* Символы масштаба пространства. Они попадают в дифф любой геометрической
* правки и упоминаются любым геометрическим смоком, поэтому отвечают на вопрос
* «это про геометрию?», а не «какой контракт затронут». Оставленные в выборке,
* они приводили дюжину смоков без единого содержательного основания.
*
* Список именно списком, а не порогом по частоте: `_cellCm` встречается всего в
* пяти смоках, то есть по частоте он редкий — а различает всё равно ничего.
*/
const GENERIC_SYMBOLS = new Set(['_cellCm', '_gridPitch', '_wallKeyPitch']);
const IDENTIFIER = /[A-Za-z_$][\w$]{2,}/g;
export function symbolTable(root = repoRoot) {
const table = new Set();
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
(a, b) => a.name.localeCompare(b.name),
)) {
const path = join(dir, entry.name);
if (entry.isDirectory()) { walk(path); continue; }
if (!entry.name.endsWith('.ts') || entry.name.endsWith('.d.ts')) continue;
const text = readFileSync(path, 'utf8');
for (const pattern of DECLARATIONS) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text))) {
if (match[1].length >= 4 && isDistinctive(match[1])) table.add(match[1]);
}
}
}
};
walk(join(root, 'src'));
return table;
}
/** Изменённые файлы и символы на изменённых строках. */
export function parseDiff(diffText, table) {
const files = new Set();
const executable = new Set();
const symbols = new Set();
let current = null;
let currentExecutable = false;
for (const line of diffText.split('\n')) {
const header = /^\+\+\+ b\/(.+)$/.exec(line) || /^diff --git a\/\S+ b\/(.+)$/.exec(line);
if (header) {
current = header[1] === '/dev/null' ? null : header[1];
currentExecutable = !!current && isExecutableFrontend(current);
if (current) {
files.add(current);
if (currentExecutable) executable.add(current);
}
continue;
}
if (!currentExecutable) continue;
if (!/^[+-]/.test(line) || /^(\+\+\+|---)/.test(line)) continue;
IDENTIFIER.lastIndex = 0;
let match;
while ((match = IDENTIFIER.exec(line))) {
if (table.has(match[0])) symbols.add(match[0]);
}
}
return {
files: [...files].sort(),
executable: [...executable].sort(),
symbols: [...symbols].sort(),
};
}
/** Тексты смоков: имя файла → содержимое. */
export function smokeCorpus(root = repoRoot) {
const dir = join(root, 'demo');
const corpus = new Map();
for (const name of readdirSync(dir).filter((f) => /^smoke_.*\.mjs$/.test(f)).sort()) {
corpus.set(name, readFileSync(join(dir, name), 'utf8'));
}
return corpus;
}
const mentions = (text, symbol) =>
new RegExp(`(?<![\\w$])${symbol.replace(/\$/g, '\\$')}(?![\\w$])`).test(text);
/**
* Ядро выборки. Возвращает данные, а не текст: печать и коды выхода — дело
* вызывающего, а тест сравнивает структуру.
*/
export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {}) {
const symbols = table || symbolTable(root);
const smokes = corpus || smokeCorpus(root);
const parsed = parseDiff(diffText, symbols);
const broadLimit = Math.max(1, Math.floor(smokes.size * BROAD_SHARE));
const spread = new Map();
for (const symbol of parsed.symbols) {
let count = 0;
for (const text of smokes.values()) if (mentions(text, symbol)) count++;
spread.set(symbol, count);
}
const broad = parsed.symbols.filter(
(s) => spread.get(s) > broadLimit || (GENERIC_SYMBOLS.has(s) && spread.get(s) > 0),
);
const narrow = parsed.symbols.filter(
(s) => spread.get(s) > 0 && spread.get(s) <= broadLimit && !GENERIC_SYMBOLS.has(s),
);
const unseen = parsed.symbols.filter((s) => spread.get(s) === 0);
// Одно распространённое имя — повод посмотреть, а не вывод. Сильной связь
// считается либо по двум и более совпадениям, либо по одному редкому символу:
// `_draftSegmentCms` есть в восьми смоках и сам по себе адресует контракт,
// `_path` — в восемнадцати и адресует «здесь рисуют».
const rareLimit = Math.max(1, Math.floor(smokes.size * 0.05));
const direct = [];
for (const [smoke, text] of smokes) {
const hit = narrow.filter((symbol) => mentions(text, symbol));
if (!hit.length) continue;
const strong = hit.length > 1 || spread.get(hit[0]) <= rareLimit;
direct.push({ smoke, symbols: hit, count: hit.length, strong });
}
direct.sort((a, b) => Number(b.strong) - Number(a.strong)
|| b.count - a.count || a.smoke.localeCompare(b.smoke));
const directNames = new Set(direct.map((entry) => entry.smoke));
const registered = registeredSmokes(parsed.symbols)
.filter((entry) => !directNames.has(entry.smoke));
return {
files: parsed.files,
executable: parsed.executable,
symbols: parsed.symbols,
broad,
unseen,
direct,
registered,
smokeCount: smokes.size,
broadLimit,
// Дифф исполняемый, а связь не доказана — единственное состояние, о котором
// нельзя молчать. `noExecutableDiff` от него отличается: там и правда
// нечего проверять (docs, i18n-строки без кода, чистая инфраструктура).
noExecutableDiff: parsed.executable.length === 0,
unproven: parsed.executable.length > 0
&& !direct.some((entry) => entry.strong) && !registered.length,
};
}
function gitDiff(base, head) {
const result = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', `${base}...${head}`],
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (result.status !== 0) {
// `A...B` не работает без общего предка — тогда честнее прямой диапазон,
// чем молча вернуть пустоту и «смоки не нужны».
const plain = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', base, head],
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (plain.status !== 0) throw new Error(`git diff: ${plain.stderr || result.stderr}`);
return plain.stdout;
}
return result.stdout;
}
function report(selection) {
const lines = [];
if (selection.noExecutableDiff) {
lines.push('Исполняемого frontend-диффа нет (src/**/*.ts не тронут).');
lines.push('Browser-smoke этим диффом не выбираются — это не «пропустить проверки»,');
lines.push('а «выбирать нечего»: смоки проверяют собранную карточку.');
if (selection.files.length) lines.push(`Тронуто файлов: ${selection.files.length}.`);
return lines.join('\n');
}
lines.push(`Изменено файлов src/**: ${selection.executable.length}`
+ ` · символов проекта на изменённых строках: ${selection.symbols.length}`);
lines.push(`Матрица: ${selection.smokeCount} смоков · порог «широкого» символа:`
+ ` больше ${selection.broadLimit} смоков`);
lines.push('');
const strong = selection.direct.filter((entry) => entry.strong);
const weak = selection.direct.filter((entry) => !entry.strong);
const listing = (entries) => {
for (const entry of entries) {
lines.push(` demo/${entry.smoke}`);
lines.push(` ← ${entry.symbols.slice(0, 6).join(', ')}`
+ (entry.symbols.length > 6 ? ` и ещё ${entry.symbols.length - 6}` : ''));
}
};
if (strong.length) {
lines.push(`Прямое совпадение (${strong.length}):`);
listing(strong);
lines.push('');
}
if (weak.length) {
lines.push(`Слабая связь — одно распространённое имя, решает ревьюер`
+ ` (${weak.length}):`);
listing(weak);
lines.push('');
}
if (selection.registered.length) {
lines.push(`Зарегистрированная связь (${selection.registered.length}):`);
for (const entry of selection.registered) {
lines.push(` demo/${entry.smoke}`);
lines.push(` ← ${entry.symbols.join(', ')}`);
for (const because of entry.because) lines.push(` ${because}`);
}
lines.push('');
}
if (selection.unproven) {
lines.push('НЕОПРЕДЕЛЁННОСТЬ: дифф исполняемый, но ни один смок не связан'
+ ' доказуемо.');
lines.push('Это не значит «смоки не нужны»: значит, что связь не доказана'
+ ' и решает ревьюер.');
if (selection.broad.length) {
lines.push(`Широкие символы (есть почти везде, ничего не различают):`
+ ` ${selection.broad.slice(0, 10).join(', ')}`);
}
if (selection.unseen.length) {
lines.push('Символы, которых нет ни в одном смоке:'
+ ` ${selection.unseen.slice(0, 10).join(', ')}`
+ (selection.unseen.length > 10 ? ` и ещё ${selection.unseen.length - 10}` : ''));
lines.push('Если один из них — новый контракт, ему нужен новый смок либо'
+ ' запись в scripts/smoke-links.mjs.');
}
lines.push('');
} else if (selection.broad.length) {
lines.push(`Не учитывались как широкие: ${selection.broad.slice(0, 10).join(', ')}`);
lines.push('');
}
lines.push('Выборка дополняет AC задачи и суждение ревьюера, а не заменяет их.');
lines.push('Полный прогон матрицы остаётся предрелизной обязанностью на точном SHA.');
return lines.join('\n');
}
function main(argv) {
const arg = (name) => {
const index = argv.indexOf(name);
return index >= 0 ? argv[index + 1] : undefined;
};
const diffPath = arg('--diff');
const base = arg('--base');
const head = arg('--head') || 'HEAD';
let diffText;
if (diffPath) {
diffText = diffPath === '-' ? readFileSync(0, 'utf8') : readFileSync(diffPath, 'utf8');
} else if (base) {
diffText = gitDiff(base, head);
} else {
console.error('использование: smoke-select.mjs --base <ref> [--head <ref>] | --diff <файл|->');
return 2;
}
const selection = selectSmokes(diffText);
if (argv.includes('--json')) {
console.log(JSON.stringify(selection, null, 2));
} else {
console.log(report(selection));
}
return 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
}
+329
View File
@@ -0,0 +1,329 @@
commit c8e9597228143530b98e9a984139dc1b0c193d0e
Author: Sergey Matyunin <s.matyunin@justbusiness.site>
Date: Fri Aug 21 16:47:27 2026 +0300
fix: resolve chain segment thickness in one place
Issue: #234
User-Visible: yes
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index a3d21d6..48e3e87 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -158,7 +158,7 @@ import {
} from './plan-snap-overlay';
import {
atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs,
- normalizeUnifiedWallTool, wallChainSegments,
+ normalizeUnifiedWallTool, wallChainSegments, chainSegmentCms,
type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment,
} from './wall-face-graph';
import {
@@ -2617,7 +2617,9 @@ class HouseplanCard extends LitElement {
if (draft) {
this._activeDraftId = draft.id;
this._path = draft.points.map((point) => [...point]);
- this._draftSegmentCms = draft.segments.map((segment) => segment.cm);
+ this._draftSegmentCms = this._adoptDraftCms(
+ this._path, draft.segments.map((segment: any) => segment.cm), draft.id,
+ );
this._resumeDraftBySpace[this._space] = draft.id;
} else {
this._activeDraftId = null;
@@ -6574,7 +6576,11 @@ class HouseplanCard extends LitElement {
return true;
}
const segments = wallChainSegments(
- this._path, this._draftSegmentCms, DRAW_WALL_DEFAULT_CM,
+ this._path,
+ chainSegmentCms(
+ this._path.length - 1, this._draftSegmentCms,
+ this._drawWallCm, DRAW_WALL_DEFAULT_CM,
+ ),
);
const segmentCount = segments.length;
if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) {
@@ -7268,9 +7274,13 @@ class HouseplanCard extends LitElement {
this._path = endHit.reverse
? [...endHit.draft.points].reverse().map((p) => [...p])
: endHit.draft.points.map((p) => [...p]);
- this._draftSegmentCms = endHit.reverse
- ? [...endHit.draft.segments].reverse().map((s) => s.cm)
- : endHit.draft.segments.map((s) => s.cm);
+ this._draftSegmentCms = this._adoptDraftCms(
+ this._path,
+ endHit.reverse
+ ? [...endHit.draft.segments].reverse().map((s) => s.cm)
+ : endHit.draft.segments.map((s) => s.cm),
+ endHit.draft.id,
+ );
return;
}
this._activeDraftId = null;
@@ -7289,8 +7299,16 @@ class HouseplanCard extends LitElement {
return;
}
if (!this._canAppendRoomDraftPoint()) return;
+ // Точка и толщина её отрезка пишутся вместе (#234). Раньше запись жила в
+ // отдельном методе, который молча выходил при невалидном поле толщины, и
+ // тогда `_draftSegmentCms` становился короче числа отрезков: превью
+ // показывало текущее поле, а запись — 15 см. Инвариант читается прямо
+ // здесь, а не выводится из двух проверок в разных местах.
+ const cm = this._drawWallCm;
+ if (cm == null) { this._showPhysicalRange(100); return; }
const beforePath = this._path.map((point) => [...point]);
this._path = [...this._path, pt];
+ this._draftSegmentCms = [...this._draftSegmentCms, cm];
this._persistActiveDraftSegment();
this._offerWallFaces(beforePath);
}
@@ -7395,8 +7413,10 @@ class HouseplanCard extends LitElement {
sp.room_drafts.push(saved);
this._activeDraftId = id;
this._resumeDraftBySpace[this._space] = id;
- this._draftSegmentCms = persistedSegments.map((s: any) => Number(s.cm));
this._path = persistedPoints;
+ this._draftSegmentCms = this._adoptDraftCms(
+ persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id,
+ );
this._physicalSel = null;
this._recordGeometry(this._t('history.draft_merge'), before);
this._saveConfig();
@@ -7406,12 +7426,35 @@ class HouseplanCard extends LitElement {
}
}
- /** Persist every completed draft segment immediately. */
+ /**
+ * Thickness array adopted from storage, brought to the length of the path.
+ *
+ * A record written before #234 may be shorter than the path: the resolver
+ * fills the gaps by the same rule the preview and the writers use, so a
+ * resumed draft cannot carry a hidden 15 cm into the next save. Reported to
+ * the console rather than to the user: the person did not cause it and cannot
+ * fix it.
+ */
+ private _adoptDraftCms(path: readonly (readonly number[])[], recorded: readonly (number | null | undefined)[], id?: string): number[] {
+ const count = Math.max(0, path.length - 1);
+ const resolved = chainSegmentCms(count, recorded, this._drawWallCm, DRAW_WALL_DEFAULT_CM);
+ if (recorded.length !== count) {
+ console.debug(
+ `[houseplan] draft ${id ?? '?'}: восстановлено толщин ${count - recorded.length} (#234)`,
+ );
+ }
+ return resolved;
+ }
+
+ /**
+ * Persist every completed draft segment immediately.
+ *
+ * The thickness of the new segment is already recorded by the caller (#234):
+ * this method must not decide whether to record it, or the array and the path
+ * drift apart the moment the toolbar field is mid-edit.
+ */
private _persistActiveDraftSegment(): void {
if (this._path.length < 2 || !this._curSpaceCfg) return;
- const cm = this._drawWallCm;
- if (cm == null) return;
- this._draftSegmentCms = [...this._draftSegmentCms, cm];
const before = this._geometrySnapshot();
const sp = this._curSpaceCfg as any;
sp.room_drafts ||= [];
@@ -12383,10 +12426,17 @@ class HouseplanCard extends LitElement {
return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm)
?? DRAW_WALL_DEFAULT_CM;
}
+ // Толщина отрезка активной цепочки решается тем же резолвером (#234):
+ // именно это значение подсвечивает инструмент «Толщина», и расхождение с
+ // записью здесь было тем способом, которым дефект и обнаружился.
+ const resolved = chainSegmentCms(
+ Math.max(0, activePath.length - 1), activeCms,
+ this._drawWallCm, DRAW_WALL_DEFAULT_CM,
+ );
for (let i = 0; i + 1 < activePath.length; i++) {
const a = activePath[i], b = activePath[i + 1];
if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) {
- return validCm(activeCms[i]) ?? DRAW_WALL_DEFAULT_CM;
+ return resolved[i] ?? DRAW_WALL_DEFAULT_CM;
}
}
return DRAW_WALL_DEFAULT_CM;
@@ -12441,7 +12491,11 @@ class HouseplanCard extends LitElement {
const partitions: Array<{ a: number[]; b: number[]; cm: number }> = [];
if (!accepted.length) {
partitions.push(...wallChainSegments(
- batch.activePath, batch.activeCms, DRAW_WALL_DEFAULT_CM,
+ batch.activePath,
+ chainSegmentCms(
+ batch.activePath.length - 1, batch.activeCms,
+ this._drawWallCm, DRAW_WALL_DEFAULT_CM,
+ ),
));
} else {
const consumed = new Set(accepted.flatMap((decision) => decision.candidate.atomKeys));
@@ -12656,8 +12710,12 @@ class HouseplanCard extends LitElement {
// Draw-session wall thickness: apply to new edges only; keep neighbour cm
// on shared stretches. Split naming does not use the Draw field.
if (!wasSplit) {
- const edgeCms = [...this._draftSegmentCms, this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM];
- const cm = edgeCms[0] || this._drawWallCm;
+ const edgeCms = chainSegmentCms(
+ verts.length,
+ [...this._draftSegmentCms, this._closingWallCm ?? undefined],
+ this._drawWallCm, DRAW_WALL_DEFAULT_CM,
+ );
+ const cm = edgeCms[0];
if (cm != null) {
this._cfgEpoch++; // the new room must be in the model before keying
const openCuts = this._openCuts();
@@ -12681,7 +12739,7 @@ class HouseplanCard extends LitElement {
return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this._gridPitch * 0.02;
});
if (source >= 0) next = setWallThickness(
- next, iv.a, iv.b, edgeCms[source] || cm,
+ next, iv.a, iv.b, edgeCms[source],
this._wallKeyPitch, NORM_W,
);
}
@@ -12759,7 +12817,9 @@ class HouseplanCard extends LitElement {
if (!draft) { delete this._resumeDraftBySpace[this._space]; return; }
this._activeDraftId = id;
this._path = draft.points.map((p) => [...p]);
- this._draftSegmentCms = draft.segments.map((s) => s.cm);
+ this._draftSegmentCms = this._adoptDraftCms(
+ this._path, draft.segments.map((s: any) => s.cm), draft.id,
+ );
this._clearPlanSnapHover();
}
@@ -13029,8 +13089,14 @@ class HouseplanCard extends LitElement {
return;
}
const before = this._geometrySnapshot();
- const cms = [...this._draftSegmentCms,
- this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM];
+ // Замкнутый контур: отрезков столько же, сколько вершин, и последний —
+ // закрывающий. Его известное значение подаётся резолверу как запись, всё
+ // остальное решает единое правило (#234).
+ const cms = chainSegmentCms(
+ verts.length,
+ [...this._draftSegmentCms, this._closingWallCm ?? undefined],
+ this._drawWallCm, DRAW_WALL_DEFAULT_CM,
+ );
sp.partitions ||= [];
const seed = Date.now().toString(36);
for (let i = 0; i < verts.length; i++) {
@@ -13039,7 +13105,7 @@ class HouseplanCard extends LitElement {
id: `partition-${seed}-${i}`,
a: [a[0] / NORM_W, a[1] / NORM_W],
b: [b[0] / NORM_W, b[1] / NORM_W],
- cm: cms[i] || DRAW_WALL_DEFAULT_CM,
+ cm: cms[i],
});
}
if (this._activeDraftId && Array.isArray(sp.room_drafts)) {
@@ -18544,15 +18610,17 @@ class HouseplanCard extends LitElement {
if (this._cursorPt) return [...path, this._cursorPt];
return path.length >= 2 ? path : null;
})();
+ // Превью берёт толщины из того же резолвера, что и запись (#234). Раньше
+ // здесь была вторая формула, и расходились они ровно на пропуске: на экране
+ // текущее поле, в конфиге — 15 см.
const previewHalfDepths = previewPts
- ? previewPts.slice(0, -1).map((_, i) => {
- const cm = Number(this._draftSegmentCms[i]) > 0
- ? Number(this._draftSegmentCms[i])
- : this._contourClosed && i === previewPts.length - 2
- ? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM)
- : (drawCm || DRAW_WALL_DEFAULT_CM);
- return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2;
- })
+ ? chainSegmentCms(
+ previewPts.length - 1,
+ this._contourClosed
+ ? [...this._draftSegmentCms, this._closingWallCm ?? undefined]
+ : this._draftSegmentCms,
+ drawCm, DRAW_WALL_DEFAULT_CM,
+ ).map((cm) => wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2)
: [];
const previewD = previewPts
? drawWallPreviewD(
diff --git a/src/wall-face-graph.ts b/src/wall-face-graph.ts
index 202cf92..380ab63 100644
--- a/src/wall-face-graph.ts
+++ b/src/wall-face-graph.ts
@@ -55,10 +55,60 @@ export function normalizeUnifiedWallTool(value: unknown): unknown {
}
/** Immutable open-chain projection used by explicit finish and full rejection. */
+/**
+ * Thickness of every segment in a chain — the single answer to that question.
+ *
+ * Issue #234: five call sites decided it independently and disagreed in three
+ * different ways. The preview filled a gap with the toolbar field, the two
+ * partition writers with a hard-coded 15 cm, the room writer with the first
+ * edge's value. So a chain drawn at 30 cm was shown at 30 and stored at 15, and
+ * the owner discovered it much later by hovering a wall. Two formulas for one
+ * meaning always drift; there is exactly one here now.
+ *
+ * A missing record inherits the previous segment of the same chain, then the
+ * toolbar field, then the default (owner's decision 2026-08-21): that is what
+ * the person saw on screen while drawing, and a global default is not.
+ *
+ * Strictly positive is the validity boundary. The previous `wallChainSegments`
+ * accepted a recorded zero, which cannot be drawn through the UI (1..100 cm,
+ * `docs/WALL-THICKNESS.md`) but can sit in an old draft.
+ */
+export function chainSegmentCms(
+ segmentCount: number,
+ recorded: readonly (number | null | undefined)[] | null | undefined,
+ activeCm: number | null | undefined,
+ defaultCm: number,
+): number[] {
+ const count = Number.isFinite(segmentCount) && segmentCount > 0
+ ? Math.floor(segmentCount) : 0;
+ const valid = (value: unknown): number | null =>
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
+ // `defaultCm` — ответственность вызывающего: он передаёт
+ // DRAW_WALL_DEFAULT_CM. Константа сюда не импортируется намеренно — этот
+ // модуль не зависит ни от чего, и второе место, где живёт число 15, было бы
+ // ровно тем дублированием, которое задача и убирает. Невалидный default —
+ // дефект вызывающего, поэтому он приводится к минимальной допустимой
+ // толщине (1 см, docs/WALL-THICKNESS.md), а не к выдуманному значению.
+ const fallbackTail = valid(activeCm) ?? valid(defaultCm) ?? 1;
+ const out: number[] = [];
+ let previous: number | null = null;
+ for (let i = 0; i < count; i++) {
+ const own = valid(recorded?.[i]);
+ const cm = own ?? previous ?? fallbackTail;
+ out.push(cm);
+ previous = cm;
+ }
+ return out;
+}
+
+/**
+ * Drawable segments of a chain. Thickness arrives already resolved (#234): this
+ * function no longer owns a fallback of its own, because owning one is how the
+ * disagreement started.
+ */
export function wallChainSegments(
path: readonly (readonly number[])[],
cms: readonly number[],
- defaultCm: number,
): WallChainSegment[] {
const result: WallChainSegment[] = [];
for (let i = 0; i + 1 < path.length; i++) {
@@ -66,8 +116,9 @@ export function wallChainSegments(
const b = path[i + 1];
if (!finitePoint(a) || !finitePoint(b)
|| Math.hypot(b[0] - a[0], b[1] - a[1]) <= Number.EPSILON) continue;
- const cm = Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : defaultCm;
- result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm });
+ // The resolver guarantees a positive number per index; a caller that skips
+ // it is a defect, so the value is used as given rather than re-defaulted.
+ result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] });
}
return result;
}
+42
View File
@@ -0,0 +1,42 @@
commit abfaae3e38b86769c39692d62dd146424ad0255c
Author: Matysh <s.matyunin@justbusiness.site>
Date: Sat Aug 22 09:45:15 2026 +0300
feat: measure resize labels between wall faces
Issue: #233
User-Visible: yes
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a9ae244..be221a9 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,15 @@
## Unreleased
+- Resizing a room now reports the distance between wall faces, the number a tape
+ measure gives: a 300 cm span between 15 cm walls reads 285 cm. The area label
+ was already computed from the floor, so one bubble used to carry two
+ conventions and neither number could be checked against anything. A passage or
+ a side open to the next room keeps its full length, because there is no face to
+ measure from. Area still subtracts columns and partitions, so length times
+ length may differ from it — that gap is furniture in the room, not a change of
+ ruler ([#233](https://github.com/Matysh/houseplan-card/issues/233)).
+
- A chain of walls now stores the thickness it was drawn with. A segment whose
thickness was not recorded — which happened while the toolbar field was being
edited between segments — used to be previewed at the drawn value and saved at
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 3adaa08..c4d57ef 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -295,7 +295,7 @@ Other operations edit existing geometry:
|---|---|
| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
-| Resize | Moves a wall with shared geometry or scales a room by corner handles |
+| Resize | Moves a wall with shared geometry or scales a room by corner handles. Live labels report **inner** dimensions — the clear distance between wall faces, the number a tape measure gives — and the room area |
| Thickness | Changes one physical span or every wall of a room |
| Delete room | Deletes only the selected room after confirmation |
+10
View File
@@ -0,0 +1,10 @@
diff --git a/src/wall-thickness.ts b/src/wall-thickness.ts
index 1111111..2222222 100644
--- a/src/wall-thickness.ts
+++ b/src/wall-thickness.ts
@@ -2508,7 +2508,7 @@
-export function openingInnerFaceOffsetFromIndex(
+export function openingInnerFaceOffsetFromIndex(
index: OpeningWallIndex,
- const piece = centrePiece(selected);
+ const piece = tunnelFacePath(selected);
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { selectSmokes, parseDiff, symbolTable } from '../scripts/smoke-select.mjs';
import { SMOKE_LINKS, registeredSmokes } from '../scripts/smoke-links.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const fixture = (name) =>
readFileSync(join(repoRoot, 'test/fixtures/smoke-select', name), 'utf8');
// Выборка считается по настоящему корпусу смоков и настоящей таблице символов:
// фикстура — только дифф. Тест поэтому краснеет и когда ломается инструмент, и
// когда контракт переименовали, не обновив связи. Второе — тоже дефект.
const names = (selection) => selection.direct.map((entry) => entry.smoke);
const strongNames = (selection) => selection.direct
.filter((entry) => entry.strong).map((entry) => entry.smoke);
test('#234: выборка находит все четыре смока контрольного случая (#241)', () => {
const selection = selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot });
const recommended = new Set([
...strongNames(selection),
...selection.registered.map((entry) => entry.smoke),
]);
for (const smoke of [
'smoke_wall_chain_thickness.mjs',
'smoke_draw_wall_thickness.mjs',
'smoke_wall_thickness_transition.mjs',
'smoke_wall_junctions.mjs',
]) {
assert.ok(recommended.has(smoke), `${smoke} не попал в выборку по диффу #234`);
}
// Именно тот смок, на котором #234 потерял регресс, и именно по имени поля:
// если связь начнёт находиться «вообще как-нибудь», проверка обесценится.
const junctions = selection.direct.find((e) => e.smoke === 'smoke_wall_junctions.mjs');
assert.ok(junctions.symbols.includes('_draftSegmentCms'));
// И не превращается в полный прогон: смысл выборки в том, что она меньше матрицы.
assert.ok(recommended.size < selection.smokeCount / 4,
`выборка ${recommended.size} из ${selection.smokeCount} — это уже полная матрица`);
});
test('#234: переход толщин держится на зарегистрированной связи, а не на совпадении', () => {
const selection = selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot });
// Смок не называет ни одного изменённого символа — если он вдруг окажется в
// прямых совпадениях, значит реестр перестал быть нагруженным, и мутант его
// удаления ничего не докажет.
assert.ok(!names(selection).includes('smoke_wall_thickness_transition.mjs'),
'смок перехода нашёлся по совпадению — реестр надо пересобрать заново');
const entry = selection.registered.find(
(candidate) => candidate.smoke === 'smoke_wall_thickness_transition.mjs',
);
assert.ok(entry, 'зарегистрированная связь #234 пропала');
assert.ok(entry.symbols.includes('chainSegmentCms'));
assert.ok(entry.because.join(' ').length > 40, 'связь без объяснения — суеверие');
});
test('только документация: выборка пуста и говорит почему (#241)', () => {
const selection = selectSmokes(fixture('docs-only.diff'), { root: repoRoot });
assert.equal(selection.noExecutableDiff, true);
assert.deepEqual(selection.direct, []);
assert.deepEqual(selection.registered, []);
// «Нечего выбирать» и «неопределённость» — разные ответы, и путать их нельзя.
assert.equal(selection.unproven, false);
assert.ok(selection.files.length > 0, 'файлы в диффе всё же были');
});
test('связь не доказана — это неопределённость, а не «проверок не нужно» (#241)', () => {
const selection = selectSmokes(fixture('unproven.diff'), { root: repoRoot });
assert.equal(selection.noExecutableDiff, false);
assert.equal(selection.unproven, true);
assert.equal(strongNames(selection).length, 0);
assert.ok(selection.unseen.includes('openingInnerFaceOffsetFromIndex'),
'символ без смока обязан быть назван');
});
test('таблица символов не берёт одиночные английские слова (#241)', () => {
const table = symbolTable(repoRoot);
for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) {
assert.ok(!table.has(noise), `«${noise}» попал в таблицу символов и вернёт шум`);
}
for (const real of ['chainSegmentCms', '_draftSegmentCms', 'innerEdgeSpan']) {
assert.ok(table.has(real), `${real} не распознан как символ проекта`);
}
});
test('parseDiff читает только исполняемый frontend (#241)', () => {
const table = new Set(['chainSegmentCms']);
const parsed = parseDiff([
'diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md',
'+chainSegmentCms упомянут в документации',
'diff --git a/src/wall-face-graph.ts b/src/wall-face-graph.ts',
'+export function chainSegmentCms(',
].join('\n'), table);
assert.deepEqual(parsed.executable, ['src/wall-face-graph.ts']);
assert.deepEqual(parsed.symbols, ['chainSegmentCms'], 'упоминание в docs не символ диффа');
});
test('каждая запись реестра объясняет себя и указывает на существующий смок (#241)', () => {
for (const link of SMOKE_LINKS) {
assert.ok(link.symbols.length, 'связь без символов не сработает никогда');
assert.ok(link.because && link.because.length > 40, 'связь без объяснения — суеверие');
for (const smoke of link.smokes) {
assert.match(smoke, /^smoke_.*\.mjs$/);
assert.ok(
readFileSync(join(repoRoot, 'demo', smoke), 'utf8').length > 0,
`${smoke} в реестре, но файла нет`,
);
}
}
// Пустой набор изменённых символов не должен давать связей.
assert.deepEqual(registeredSmokes([]), []);
});