From c0a4fa29eebd1b8c7ff41e1fbb9b23855f49ecec Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 18:32:52 +0300 Subject: [PATCH] ci: derive the browser-smoke shortlist from the diff Issue: #241 User-Visible: no --- scripts/mutation-gate.mjs | 14 + scripts/smoke-links.mjs | 59 ++++ scripts/smoke-select.mjs | 334 ++++++++++++++++++ .../smoke-select/234-chain-thickness.diff | 329 +++++++++++++++++ test/fixtures/smoke-select/docs-only.diff | 42 +++ test/fixtures/smoke-select/unproven.diff | 10 + test/smoke-select.test.mjs | 114 ++++++ 7 files changed, 902 insertions(+) create mode 100644 scripts/smoke-links.mjs create mode 100644 scripts/smoke-select.mjs create mode 100644 test/fixtures/smoke-select/234-chain-thickness.diff create mode 100644 test/fixtures/smoke-select/docs-only.diff create mode 100644 test/fixtures/smoke-select/unproven.diff create mode 100644 test/smoke-select.test.mjs diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 7eb69f39..2f4d4670 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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 ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs new file mode 100644 index 00000000..2e24c4cd --- /dev/null +++ b/scripts/smoke-links.mjs @@ -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)); +} diff --git a/scripts/smoke-select.mjs b/scripts/smoke-select.mjs new file mode 100644 index 00000000..4e0b29ac --- /dev/null +++ b/scripts/smoke-select.mjs @@ -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(`(? 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 [--head ] | --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))); +} diff --git a/test/fixtures/smoke-select/234-chain-thickness.diff b/test/fixtures/smoke-select/234-chain-thickness.diff new file mode 100644 index 00000000..18df5d5d --- /dev/null +++ b/test/fixtures/smoke-select/234-chain-thickness.diff @@ -0,0 +1,329 @@ +commit c8e9597228143530b98e9a984139dc1b0c193d0e +Author: Sergey Matyunin +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; + } diff --git a/test/fixtures/smoke-select/docs-only.diff b/test/fixtures/smoke-select/docs-only.diff new file mode 100644 index 00000000..c1186335 --- /dev/null +++ b/test/fixtures/smoke-select/docs-only.diff @@ -0,0 +1,42 @@ +commit abfaae3e38b86769c39692d62dd146424ad0255c +Author: Matysh +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 | + diff --git a/test/fixtures/smoke-select/unproven.diff b/test/fixtures/smoke-select/unproven.diff new file mode 100644 index 00000000..19125ba0 --- /dev/null +++ b/test/fixtures/smoke-select/unproven.diff @@ -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); diff --git a/test/smoke-select.test.mjs b/test/smoke-select.test.mjs new file mode 100644 index 00000000..f9b5f26f --- /dev/null +++ b/test/smoke-select.test.mjs @@ -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([]), []); +});