24 KiB
CODE-REVIEW-242-r1
- Issue: #242 — «Проём в толстой стене рисуется у произвольной грани, а не по центру толщины»
- Этап: code (PROCESS.md §2.7)
- Заход: r1 (первый код-цикл; SPEC-REVIEW-242-r1 — зелёный)
- Диапазон:
origin/dev..HEAD=0033138..5e13e34(5 коммитов), реализация —6d6db12(fix(openings): center symbols across wall depth,User-Visible: yes) - Вердикт: жёлтый
Скоуп
ТЗ docs/specs/242-opening-symbol-center.md требует: символ проёма (дверь/окно/ворота)
по умолчанию центрируется по толщине стены на всех render surfaces (Flat/View,
preview, hosted Static, скрытый Iso); сохранённый flip_v: true у двери/окна
остаётся ручным edge alignment; у ворот flip_v не двигает створки, а только
меняет знак существующего 10° поворота (AC4, §4.3, §7 таблица). Направление должно
быть детерминированным и не зависеть от порядка комнат/направления независимой
стены (AC2).
Как проверялось
Прочитаны docs/SCOPE.md, PROCESS.md, docs/specs/242-opening-symbol-center.md, docs/WALL-THICKNESS.md, docs/ARCHITECTURE.md, docs/ISOMETRIC.md, docs/USER-GUIDE.ru.md, тело issue и все комментарии (аналитика, Q1–Q4, решения владельца, handoff разработчика).
Дифф прочитан построчно: src/opening-symbol-placement.ts (новый),
src/wall-thickness.ts, src/render/opening-symbol.ts, src/iso-openings.ts,
плюс unit/smoke/mutation-gate правки. src/houseplan-card.ts и
src/partition-openings.ts диффом не тронуты — они читались как неизменный
контекст, через который резолвится face.
Гейты, реально прогнанные в этой сессии:
| Гейт | Результат |
|---|---|
npx tsc --noEmit |
green |
npm test |
green, 1095/1095 |
npm run build + сверка dist / demo/srv/assets / custom_components/.../frontend |
green, все три sha256 = 3befe09b…, git status чист |
node scripts/check-docs.mjs |
green (7 файлов, 10 внешних ссылок) |
node scripts/mutation-gate.mjs --check |
green, включая 3 новых мутанта opening-symbol-* |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
«Зарегистрированная связь» → smoke_isometric_contract.mjs, smoke_wall_thickness.mjs (оба помечены автором как прогнанные и зелёные; полный вывод инструмента ниже) |
npm run golden:verify на HEAD |
red (см. раздел «Находки», M1) — прогнан дважды подряд, результат детерминирован |
npm run golden:verify на origin/dev (5bf1868, отдельный git worktree) |
red по своему меньшему набору сцен — снят как база для сравнения, не для приёмки |
Дополнительно я independently проверил геометрический контракт прямым запуском
в headless Chromium через demo/serve.mjs (тот же харнесс, что используют
существующие demo/smoke_*), а не только чтением кода — конкретно AC4 для
ворот, потому что до-релизная логика face/flip у ворот инвертирует flip_v
дважды (faceFlipV = !o.flip_v в src/houseplan-card.ts при построении face,
затем сырой o.flip_v ещё раз в sy внутри renderOpeningVisibleGeometry), и
я не был уверен в результате по одному чтению formулы. Три сценария (свежее
состояние карточки на каждый, без переиспользования state):
- Ворота на внешней (однокомнатной) толстой стене (
_wallThickClick, cm=25) —flip_v: false→ рычагиrotate(-10deg)/rotate(10deg),flip_v: true→rotate(10deg)/rotate(-10deg). Меняется, как требует AC4. - Ворота на общей стене двух комнат (тот же сценарий, что и исходный баг
— стена между
r1/r2) —flip_v: falseиflip_v: trueдают одинаковыйrotate(10deg)/rotate(-10deg); направление не меняется. Проверено дважды, плюс с перестановкойsp().rooms— результат идентичен во всех четырёх комбинациях. - Ворота на независимой стене (
host: { kind: 'partition', … }), прямые и развёрнутые endpoints —flip_v: falseиflip_v: trueтакже дают одинаковыйrotate(10deg)/rotate(-10deg)в обеих ориентациях перегородки.
Скрипты воспроизведения не коммитились (репозиторий трогать нельзя), но
последовательность — это буквально demo/smoke_wall_thickness.mjs до момента
создания стены, плюс sp().openings = [{ type: 'gate', … }] и чтение
.op-leaf style после c._cfgEpoch++/requestUpdate/updateComplete; сравнение
flip_v до/после и общей/независимой стены до/после — воспроизводится любым
инструментом с доступом к window.__card.
Находки
H1 (High, в скоупе) — AC4 не выполнен: flip_v не меняет направление 10° поворота ворот на общей стене и на независимой стене
Что не так. Спецификация (§4.3, §7 таблица: gate | true | (0,0) | противоположный знак 10° поворота) и AC4 («Gate при обоих значениях flip_v
центрирован, но знак 10° поворота меняется») требуют, чтобы флаг flip_v у
ворот всегда переключал сторону лёгкого поворота створок, при неизменном
центрированном origin. Перевод «центр не двигается» (translation) реализация
делает верно — openingSymbolOffset безусловно возвращает {0,0} для
type==='gate'. Но требование «знак меняется» не выполняется для двух из трёх
типов host, включая тот, что явно назван в самом issue как источник бага:
- общая стена двух комнат — именно тот кейс, ради которого затевалась вся
задача («порядок комнат», «то с одной стороны, то с другой»). Створки ворот
на общей стене поворачиваются одинаково при
flip_v: falseиflip_v: true(см. воспроизведение выше, сценарий 2); - независимая (partition) стена — тот самый путь, про который в issue
сказано: «сторона фиксирована знаком... то есть по умолчанию всегда одна и
та же грань» (сценарий 3). После правки трансляция действительно исчезла
(это была цель), но направление поворота по‑прежнему не реагирует на
flip_vвообще — то есть первоначальный дефект (фиксированный знак) для этого пути не устранён, просто перестал быть заметен из-за убранного смещения.
Направление меняется корректно только для ворот на однокомнатной внешней стене (сценарий 1) — самом редком практическом месте для ворот.
Причина по коду. src/houseplan-card.ts инвертирует flip_v перед
резолвом грани только для ворот: faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v (строки 11988, 18551, 12066 и в Iso-ветке 5289), затем передаёт
сырой o.flip_v в visibleSpec.flipV/sy. src/render/opening-symbol.ts
считает gateAngle = spec.face.side * sy * 10 * amount. Для стороны,
получаемой через partitionOpeningFace (side = flipV ? 1 : -1, не тронут
этим диффом) и через «общую стену» ветку wall-thickness.ts
(naturalSide = association.negative && association.positive ? -1 : available[0].side, тоже реагирует на flip_v только знаком) двойная
инверсия (faceFlipV затем sy) арифметически гасит сама себя: side * sy
даёт одно и то же значение при flip_v=false и flip_v=true. Единственный
случай, где это не совпадает — однокомнатная стена, потому что там
naturalSide берётся из available[0].side, единственного реального
кандидата, а не из пары negative/positive, и по независимым от меня
причинам (сама схема _openingFace/resolveOpeningWallAssociation) итоговый
знак там ведёт себя иначе; я подтвердил это эмпирически, а не через ручной
вывод формулы (see «Как проверялось»), и не настаиваю на объяснении «почему
именно этот случай работает» — важен наблюдаемый факт.
Кто ещё вводится в заблуждение. Оба changelog, docs/WALL-THICKNESS.md,
docs/ARCHITECTURE.md, docs/ISOMETRIC.md и docs/USER-GUIDE.ru.md прямо
утверждают обратное: «ворота остаются по центру, а флаг меняет только
направление их 10° открытия» (и англ. эквивалент «a gate flip changes only
turn direction»). Это неверно для двух из трёх host-путей.
Почему не поймано тестами. test/opening-symbol.test.mjs для ворот
проверяет rotate(10deg)/rotate(-10deg) только у одного (не-flipped)
рендера — второй (flippedGate) собирается, но его rotate(...) значения
нигде не сравниваются с первым. test/iso-openings.test.mjs сравнивает
gate/gateFlipped, но эти два фикстура одновременно меняют и flipV, и
face.side вручную — тест не воспроизводит, что в реальном рендере side
сам является функцией от flip_v через faceFlipV, поэтому не ловит гашение
знаков. demo/smoke_isometric_contract.mjs проверяет только
Math.abs(leaf.turnDeg) === 10, не сравнивая знак с неперевёрнутым вариантом.
Ни один golden-сценарий с воротами flip_v: false/true рядом не добавлен (см.
M1) — а именно такая сцена сделала бы дефект видимым на глаз.
Серьёзность. High: явный, воспроизводимый разрыв между заявленным в ТЗ/ docs контрактом и фактическим поведением для двух из трёх типов host-стен, включая тот, что был первопричиной issue. Блокирует.
Как исправить (для автора, не мной): либо не инвертировать flip_v при
резолве face для ворот в houseplan-card.ts (раз "сторона" в face.side
и так уже несёт нужный знак), либо использовать в gateAngle не
spec.face.side, а сырой !!o.flip_v/детерминированную локальную сторону
напрямую, независимо от того, как резолвер комбинирует её с flip_v
дважды. Нужна регрессионная проверка знака на пути «общая стена» и
«partition», а не только «однокомнатная стена».
M1 (Medium, в скоупе) — заявленный golden-охват в handoff занижен на порядок; обязательные по §12.3 новые сцены не добавлены
Handoff-комментарий разработчика утверждает: «Targeted golden:
opening-placement-door-thick-wall-dark и openings-thick-wall-dark ожидаемо
different». Фактический npm run golden:verify на точном 5e13e34
(прогнан дважды, детерминированно) даёт different по ~48 сценам, не по
двум — сравнение с тем же прогоном на чистом origin/dev (отдельный
git worktree, тот же локальный Chromium) показывает, что подавляющее
большинство новых расхождений (isometric-geometry-*, day-cycle-*,
lighting-*, hover-*, device-*, optimize-preflight-dialog-*,
backup-*, room-label-parity-* и др.) на dev были passed, а на HEAD стали
different. Причина ожидаемая и не является отдельным багом: эти сцены
используют общие фикстуры (golden-geometry, golden-lighting), в которых
уже стоит дверь/окно/ворота в толстой стене — символ у них теперь центрирован,
и это меняет пиксели кадра, даже когда сценарий проверяет не связанную с
проёмами вещь (солнце, hover, диалог). Это не находка «что-то сломалось
непричастное», а находка «отчёт разработчика не соответствует
действительности на порядок величины» — то же самое, что ревью #231-r1 уже
находило (M1 там: «список golden-сцен неполон») и что #231-r2 потребовало
зафиксировать явным списком в docs/TESTING.md, а не оставлять в тексте
handoff-комментария.
Отдельно: §12.3 ТЗ требует добавить новые semantic-сцены — «centered
door/window/gate на толстой room wall, Light», «те же типы on diagonal
partition, Dark», «door/window с flip_v: true и gate false/true рядом»,
«hidden Iso parity for center and gate direction» — и обновить golden semantic
guard, чтобы он до PNG-сравнения проверял wall centerline, visible-group
center, jamb depth, flip_v и gate angle. Ни одна из этих сцен не добавлена
в demo/golden/matrix.mjs (диф по этому файлу — только правка комментария
для существующей opening-placement-door-thick-wall-dark); никакого нового
semantic guard тоже нет. Именно сцена «gate false/true рядом» — то, что
сделало бы H1 видимым на глаз ещё до code review.
Серьёзность. Medium, в скоупе задачи: тестовый план §12.3 не выполнен
полностью, а фактический охват регрессии не задокументирован для
предрелизного golden:accept -- --reviewed, который будет принимать эти ~48
сцен без знания их полного списка и происхождения. Чинится добавлением
описанных в §12.3 сцен/guard и точного списка затронутых существующих сцен
(аналогично docs/TESTING.md в #231-r2) — без необходимости отдельного issue.
Что проверено и корректно
- AC1/AC2 для room-wall пути (без
flip_v, центр стены, независимость от порядка комнат).openingInnerFaceOffsetFromIndexразделяет физическую половину (piece.half/piece.cm, не зависит отflip_v) и направление (sideизresolveOpeningWallAssociation, вычисляется из локальной нормали открыванияnx,ny = f(opening.angle)и геометрическогоedge.inward, не зависит от порядкаrooms/candidateOrder).test/wall-thickness.test.mjsдобавилreversed/reversedFlipdeepEqual-проверку — я прочитал и проверил, что тест умеет падать (откатил численно вручную: без замены сортировки.orderна геометрическийsideвозвращается старое значение). - AC2 для partition-пути.
resolvePartitionOpeningнормализуетangleчерезatan2+ приведение к[-90,90), что инвариантно к разворотуa/b(проверено чтением и совпадает с уже существующим тестомtest/partition-openings.test.mjs:35, не тронутым этим диффом).openingSymbolOffsetиспользуетMath.hypot(face.ox, face.oy)— модуль, а не знак — поэтому разворотpartitionOpeningFace'sox/oy(который реально зависит от направления оси, см.axis.ux/uy) не протекает в видимое смещение. Это подтверждено и unit-тестом (test/opening-symbol-placement.test.mjs:positiveFace/negativeFaceдают одинаковый результат), и моим собственным browser-тестом сценария 3 выше (симметрия сохраняется, хоть и с той же H1-проблемой по направлению). - AC3 (door/window
flip_v: true, окно целиком).test/opening-symbol.test.mjsпроверяет, чтоop-glass/leaves/arc едут однимtranslate; я прочиталrenderOpeningVisibleGeometry— стекло действительно внутри той же<g transform="translate(...)">, что и створки/дуги. - Jambs full-depth.
jambHalfвopeningVisibleMetricsсчитается изspec.face.cmнезависимо отswingTx/swingTy— косяки не входят в переносимую группу, как требует §7. Проверено чтением, не исполнением отдельного визуального теста. - AC7 (без миграции/схемы).
src/opening-symbol-placement.ts— чистые функции,OpeningCfg.flip_vне тронут, новых полей/типов конфигурации нет.git diffподтверждает: единственный новый файл — чистый TS-модуль без side-effects. - User-Visible / трейлеры.
6d6db12несётIssue: #242иUser-Visible: yes; оба changelog обновлены в том же коммите (см.git show 6d6db12 --stat). - Три копии бандла. sha256
dist/houseplan-card.js,demo/srv/assets/houseplan-card.js,custom_components/houseplan/frontend/houseplan-card.jsсовпадают между собой и с заявленным автором хэшем;npm run buildвоспроизводит их без диффа в рабочем дереве. - Mutation-gate. Три новых мутанта (
opening-symbol-default-uses-room-face,opening-symbol-partition-follows-endpoints,opening-gate-flip-translates-leaves) проходят--check; я прочитал патчи — они действительно откатывают именно центрирование/order-independence (не гейт-обход), но, как и unit/golden выше, не покрывают H1 (мутант про ворота проверяет только «не появилась ли трансляция», а не «меняется ли знак поворота»).
Чего не проверял
- Полный Linux golden-артефакт и его reviewed-приёмка — вне цикла код-ревью
по правилу §13 (
golden:acceptтолько по полному Linux release artifact). Локальныйgolden:verify, который я прогнал, обслуживает только M1 (охват дельты), не приёмку эталонов. python -m pytest tests_backend— не прогонял:custom_components/**/*.pyдиффом не тронут.- Performance-профили — не прогонял: задача не называет их в AC, изменение
чисто геометрическое без новых аллокаций/обходов по стенам (проверено
чтением:
openingSymbolOffset— O(1) арифметика на уже разрешённомface). - Ручной пиксельный просмотр каждой из ~48 изменившихся golden-сцен — я установил только факт и причину расхождения (общая фикстура с дверью), не разбирал каждую сцену построчно; это и есть предмет M1.
- AC1 буквально на 1/15/100 см. Существующие unit-тесты (
wall-thickness.test.mjs,opening-symbol-placement.test.mjs) используют другие конкретные значения cm (15/20/25/30); отдельного sweep по 1/15/100 см для нового contract'а нет. Не поднимаю как отдельную находку — арифметика линейна поcm(piece.half, масштаб) и не имеет условных ветвей по величине толщины, риск низкий, но формальное доказательство AC1 в заявленном виде отсутствует. - Полная browser-smoke матрица (167 сценариев) — не прогонял всю. Выбор
ограничен
smoke-select-выводом (2 зарегистрированные связи, обе прогнаны автором и мной перепроверены через отдельные ad hoc browser-скрипты на реальном харнессе, не только через существующие smoke-файлы) плюс дополнительныеsmoke_partition_openings.mjs/smoke_opening_preview.mjs, которые автор тоже прогнал зелёными. Полный прогон не требуется: диф не задевает несвязанные подсистемы (unrelated golden-diff в M1 объяснён общей фикстурой, а не изменением поведения самих этих подсистем).