mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -0,0 +1,337 @@
|
||||
# CODE-REVIEW-329-r1
|
||||
|
||||
Issue: #329 · этап: code (PROCESS.md §2.7) · заход r1 · блокирующих циклов 0/4 (до этого вердикта)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Диапазон: `origin/dev...HEAD` на ветке `issue/329-junction-limits`, HEAD =
|
||||
`7017896e` (докоммит после ребейза на `dev`, который вобрал #264 —
|
||||
извлечённый `ResizeController`). Ветка чисто ребейзится на `origin/dev`
|
||||
(`git merge-base origin/dev HEAD` == tip `origin/dev`), конфликта нет — два
|
||||
предыдущих запуска конвейера, отказавшихся ревьюить из-за конфликта, здесь не
|
||||
актуальны, ветка перебазирована и запушена автором (комментарий
|
||||
2026-08-27T20:00:24Z).
|
||||
|
||||
Задача обычного трека (не `small`): ТЗ — `docs/specs/329-junction-limits.md`,
|
||||
ревизия 6, спек-ревью прошло r1→r3 (зелёный на r3), затем автор внёс правки
|
||||
владельца §4/П3 напрямую (ревизии 4–6) без дополнительного раунда
|
||||
спек-ревью — это первый code-review заход для итогового кода.
|
||||
|
||||
43 изменённых файла, из них продуктовый код: `src/junction-limits.ts` (новый),
|
||||
`src/houseplan-card.ts`, `src/wall-thickness.ts`, `src/resize-controller.ts`,
|
||||
`src/i18n/{en,ru}.json`, `custom_components/houseplan/junction_limits.py`
|
||||
(новый), `custom_components/houseplan/websocket_api.py`. Остальное — тесты,
|
||||
golden, документация, сгенерированный бандл.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты (обязательные всегда):
|
||||
|
||||
- `npx tsc --noEmit` — чисто.
|
||||
- `npm test` — 1388/1389 pass, 1 skip (питоновский parity-тест живёт в
|
||||
`tests_backend`, не в node-раннере). Ни одного red.
|
||||
- `npm run build` — собрался; `dist/houseplan-card.js` побайтово совпадает с
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` (сверено `diff -q`
|
||||
после `npm run bundle:sync`).
|
||||
- `node scripts/check-docs.mjs` — «Documentation checks passed (7 files, 10
|
||||
external links)», отпечаток скриншотов свежий (последний коммит ветки как
|
||||
раз чинит это после ребейза).
|
||||
|
||||
Геометрия/инварианты (диф трогает рёбра стен, `wall_segments`, толщину,
|
||||
`layout`):
|
||||
|
||||
- `npm run invariants -- --config test/fixtures/329-sharp-apex.json` —
|
||||
«Инварианты выполнены: ссылки разрешимы, записи толщины находятся» (ключ
|
||||
записи толщины и ключ решёточного ребра совпадают на самой фикстуре issue).
|
||||
- `npm run golden:verify` — весь текущий набор (полная матрица, не только
|
||||
новая сцена) прошёл, включая новую `sharp-apex-legacy-dark` (AC6) и уже
|
||||
существующую `junction-owner-repro-dark`; ни одна старая сцена не
|
||||
изменилась — риск §10 закрыт исполнением, а не заявлением.
|
||||
|
||||
Бэкенд (диф трогает `custom_components/houseplan/*.py`):
|
||||
|
||||
- `python -m pytest tests_backend/test_junction_limits.py -q` — 7 passed,
|
||||
включая `test_parity_with_the_frontend_checks` (реально прогнал TS-функции
|
||||
через `node --eval` и сравнил с питоновским вердиктом — не skip, `test-build/`
|
||||
был собран `npm run invariants` до этого).
|
||||
- Полный `python -m pytest tests_backend -q` в этой среде не поднимается:
|
||||
`homeassistant` не установлен (это облачная CI-среда без
|
||||
`.venv-backend`, установить полный HA недостаточно оправдано ради одного
|
||||
ревью) — падает на импорте в трёх модулях, не в затронутых #329 файлах.
|
||||
Ограничение окружения, не решение сузить проверку: описано в AGENTS.md
|
||||
(«Локально только pytest без HA — silently skips `test_ha_*.py`»). Здесь
|
||||
импорт вообще падает раньше skip-логики `conftest.py`, поэтому я запустил
|
||||
ИМЕННО целевой файл отдельно (см. выше) вместо того, чтобы полагаться на
|
||||
«зелёный», который ничего не доказывает.
|
||||
- Дополнительно: написал и выполнил собственный python-репро (см. находку
|
||||
H1) — прямой вызов `validate_junction_limits` на сконструированных
|
||||
fixtures, не полагаясь на чтение кода.
|
||||
|
||||
Мутанты (диф добавляет/меняет 4 мутанта в `scripts/mutation-gate.mjs`) —
|
||||
прогнаны точечно, не весь гейт (несоразмерно объёму ревью):
|
||||
|
||||
- `junction-limit-angle-not-enforced` — поймано 1/1.
|
||||
- `junction-limit-write-gate-removed` — поймано 1/1.
|
||||
- `degenerate-apex-bevelled-again` — поймано 1/1.
|
||||
- `resize-preview-reject-silent` (изменённый якорь) — поймано 1/1.
|
||||
|
||||
Браузерные смоки — выбраны через `node scripts/smoke-select.mjs --base
|
||||
origin/dev --head HEAD` (43 символа на изменённых строках `src/**` в 4
|
||||
файлах, порог широкого символа 38 смоков — под порог не попал ни один,
|
||||
инструмент не потребовал расширять выборку до всей матрицы 193). Прогнаны:
|
||||
оба смока, названных в AC/изменённых диффом (`smoke_junction_limits.mjs` —
|
||||
AC1/AC3/AC5a/AC7a/AC7b/§3, `smoke_island_rooms.mjs` — П3 на комнатах-островах),
|
||||
плюс все 13 «прямых совпадений» и 1 «зарегистрированная связь»
|
||||
(`smoke_real_plan_masonry` — инструмент сам напомнил, что на реальном плане
|
||||
кладка рвётся там, где синтетика цела, и восемь прошлых задач по стыкам уже
|
||||
входили в бету с необнаруженным разрывом; учитывая, что диф правит
|
||||
`insetContour`/`outsetContour`, счёл прогон обязательным, а не факультативным).
|
||||
Итог — 15/15 OK, ни один не покраснел.
|
||||
|
||||
## Находки
|
||||
|
||||
### H1 (High, в скоупе). Бэкенд отказывает в легитимной правке легаси-плана — прямое нарушение §3
|
||||
|
||||
`custom_components/houseplan/junction_limits.py::validate_junction_limits`
|
||||
сравнивает «before» и «after» по СЫРЫМ документам (`msg["config"]` и
|
||||
`data.get("config")`), не пропуская ни один из них через
|
||||
`commit_wall_segment_model`. Фронтенд ровно эту ошибку уже нашёл и
|
||||
исправил в коммите `4758767e` («junction limits judge both sides after the
|
||||
same migration») — коммит-сообщение прямым текстом объясняет механику:
|
||||
«The baseline for inheritance was the raw previous document, which for a
|
||||
legacy space carries no wall catalogue at all — so every inherited short
|
||||
segment of a real plan looked new». На бэкенде аналогичная миграция
|
||||
(`commit_wall_segment_model`) вызывается только в `import_export.py` и в
|
||||
`ws_plan_optimize` (и то — условно, при `submitted_model < WALL_SEGMENT_MODEL_VERSION`),
|
||||
но НЕ в `ws_config_set`, где живёт вызов `validate_junction_limits`
|
||||
(websocket_api.py:1334).
|
||||
|
||||
Следствие: хранимый (`previous`) документ пространства, которое ещё ни разу
|
||||
не проходило структурную запись после введения каталога `wall_segments`
|
||||
(`model_version < 8`), не содержит `wall_segments` вовсе —
|
||||
`limit_segments()` читает только `wall_segments`/`partitions`/`room_drafts`,
|
||||
поэтому `space_violations(old_space)` возвращает `[]` для такого
|
||||
пространства НЕЗАВИСИМО от реальной геометрии. Кандидат же (`msg["config"]`)
|
||||
современный клиент уже мигрировал на клиенте до отправки — там
|
||||
`wall_segments` есть. Любое унаследованное нарушение (в т.ч. безобидное,
|
||||
годами живущее в реальном плане пользователя — ровно повод, по которому
|
||||
заведён #329) на первой же структурной правке после обновления карточки
|
||||
читается как «новое» и запись ЦЕЛИКОМ отклоняется — даже если правка не
|
||||
касалась нарушающего узла. Это прямое нарушение §3 ТЗ («миграция...
|
||||
не блокируются никогда», «правка, не трогающая нарушающий элемент,
|
||||
проходит как обычно») и AC8/AC9 по факту (тест паритета в
|
||||
`test_junction_limits.py` не ловит это, потому что в его фикстурах ОБЕ
|
||||
стороны уже содержат `wall_segments` — сценарий «previous без каталога»
|
||||
не фигурирует нигде в тестах).
|
||||
|
||||
**Воспроизведено исполнением** (не только чтением кода) — прямой вызов
|
||||
модуля:
|
||||
|
||||
```python
|
||||
previous = {"spaces": [{
|
||||
"id": "s", "cell_cm": 5.0, "model_version": 0, "rooms": [],
|
||||
"walls": [ # легаси-хранение, БЕЗ ключа "wall_segments"
|
||||
{"key": "w0", "a": [0.0, 0.0], "b": [...], "cm": 15},
|
||||
{"key": "w1", "a": [0.0, 0.0], "b": [...], "cm": 15}, # угол 9°
|
||||
],
|
||||
}]}
|
||||
candidate = {"spaces": [{
|
||||
"id": "s", "cell_cm": 5.0, "model_version": 9,
|
||||
"rooms": [{"id": "r1", "name": "Renamed"}], # НЕСВЯЗАННАЯ правка
|
||||
"wall_segments": [ # тот же угол 9°, клиент уже мигрировал
|
||||
{"id": "w0", ...}, {"id": "w1", ...},
|
||||
],
|
||||
}]}
|
||||
jl.validate_junction_limits(candidate, previous)
|
||||
# → JunctionLimitError: junction_limit_angle; actual=9; limit=15
|
||||
```
|
||||
|
||||
Результат: `REFUSED: junction_limit_angle` — легитимная переименование
|
||||
комнаты на давно существующем плане с историческим острым углом отклоняется
|
||||
целиком. Реальный масштаб поражения велик: сама мотивация #329 — план
|
||||
РЕАЛЬНОГО пользователя с давним острым углом; после этой задачи такой
|
||||
пользователь не сможет сохранить вообще никакую правку своего плана, пока
|
||||
не прогонит Optimize (о котором он может не знать) — притом что §3 прямо
|
||||
обещает обратное, и фронтенд этот же случай уже умеет обрабатывать
|
||||
корректно (проверено смоком `§3: унаследованное нарушение не блокирует
|
||||
несвязанную запись` внутри `smoke_junction_limits.mjs`, который проходит
|
||||
именно потому, что бьёт только по фронтенд-пути).
|
||||
|
||||
Чинится по образцу уже принятого решения: перед сравнением пропустить
|
||||
`old_space` (и, для симметрии, `space`) через `commit_wall_segment_model`
|
||||
— тем же приёмом, каким `_junctionLimitsIntroduced` в `houseplan-card.ts`
|
||||
уже чинит идентичный дефект на фронтенде. `docs/CONFIG-COMPATIBILITY.md`
|
||||
(этот же дифф) уже ОБЕЩАЕТ это поведение текстом «both have gone through
|
||||
the same commitWallSegmentModel migration» — то есть документация описывает
|
||||
контракт, которого бэкенд не реализует.
|
||||
|
||||
### M1 (Medium, в скоупе). Мёртвый код в `wall-thickness.ts`, чьё удаление коммит уже задекларировал
|
||||
|
||||
Коммит `002795f7` («fix: no jags on the edges of a degenerate apex»)
|
||||
убрал оба места, где вызывались `degenerateApexCaps()`/`clipPolygonOutsideCap()`,
|
||||
и сообщение коммита прямо утверждает: «The clip helper and its cap plumbing
|
||||
are gone». На деле в текущем HEAD остаются:
|
||||
|
||||
- `apexCaps?: number[][][]` — поле интерфейса `MultiWallRoomRing`
|
||||
(`src/wall-thickness.ts:2815`), никогда не устанавливается и не читается;
|
||||
- `export function clipPolygonOutsideCap(...)` (строка 3637, ~30 строк);
|
||||
- `export function degenerateApexCaps(...)` (строка 3696, ~60 строк).
|
||||
|
||||
Ни один из них не вызывается из продуктового кода (`grep` по `src/` даёт
|
||||
только сами определения) и не покрыт тестами (`grep` по `test/` — ноль
|
||||
совпадений). Реальный переход на «острие вместо фаски» сделан иначе —
|
||||
через `isDegenerateApexCorner` прямо в `insetContour`/`outsetContour` — эти
|
||||
функции остались как нетронутый, непроверяемый и вводящий в заблуждение
|
||||
довесок: следующий читающий код решит, что это часть работающего конвейера
|
||||
клиппинга (интерфейс даже комментирует поле как «#329 §4: clip regions...»),
|
||||
хотя оно мертво с коммита, заявившего обратное.
|
||||
|
||||
### M2 (Medium, в скоупе). Дублированный раздел в `docs/USER-GUIDE.ru.md`
|
||||
|
||||
Раздел «### Ограничения стыков стен» вставлен ДВАЖДЫ подряд, слово в слово
|
||||
(строки 420–443 и 445–468 идентичны по всему тексту, включая таблицу и оба
|
||||
абзаца после неё). Английская версия (`docs/USER-GUIDE.md`, «### Wall
|
||||
junction limits») дублирования не имеет — это чисто копипаст-дефект русской
|
||||
версии, попавший в тот же коммит, что и остальная документация задачи
|
||||
(`User-Visible: yes` требует правки обоих changelog и, по духу того же
|
||||
правила, аккуратной документации на обоих языках).
|
||||
|
||||
### M3 (Medium, в скоупе). USER-GUIDE описывает канал Resize неполно — читатель не узнает про тост
|
||||
|
||||
И `docs/USER-GUIDE.md`, и `docs/USER-GUIDE.ru.md` в новом разделе пишут:
|
||||
«рисование и «Толщина» показывают тост с названием правила, а Resize
|
||||
останавливает стену в последней разрешённой позиции» (RU, строки 441–443/
|
||||
466–468; аналогично EN). Формулировка явно противопоставляет тост (для
|
||||
рисования/Толщины) и «просто останавливается» (для Resize) — то есть
|
||||
читатель разумно поймёт, что при Resize тоста нет.
|
||||
|
||||
По факту `src/houseplan-card.ts` (строки ~9181–9188) показывает тост и для
|
||||
Resize: `resize.limit_stopped` + `_junctionLimitLabel(...)`, что подтверждено
|
||||
исполнением — смок `smoke_junction_limits.mjs` проверяет
|
||||
`resizeRefusalNamesRule` (тост содержит «5») и `resizeRefusalOnce === 1`, и
|
||||
оба зелёные. Это ровно тот класс дефекта, который проверяет
|
||||
`test/single-source-numbers.test.mjs` для чисел, но здесь смысловая
|
||||
сторона: сам факт наличия канала обратной связи задокументирован неверно.
|
||||
AC7a (ревизия 6 спека) корректно описывает тост-канал — расхождение
|
||||
только в изданном для пользователя `USER-GUIDE`, который вправляли отдельно
|
||||
и не сверили с финальным текстом AC7a.
|
||||
|
||||
Смежное наблюдение (не отдельная находка, отметка для автора): §2 самого
|
||||
ТЗ (нормативный текст) тоже не обновлён вслед за AC7a ревизии 6 и всё ещё
|
||||
говорит «тост не используется» для Resize — но ТЗ на этапе code-review не
|
||||
правится, отмечаю для полноты картины, поскольку именно расхождение §2 с
|
||||
AC уже один раз обсуждалось на спек-ревью (r2-M1) и, похоже, вернулось в
|
||||
другом месте документа.
|
||||
|
||||
### M4 (Medium, в скоупе). AC10 (постусловие Optimize) не доказан ни тестом, ни явным разбором
|
||||
|
||||
AC10: «Optimize на легаси-фикстуре не создаёт новых нарушений
|
||||
(AC-постусловие §3)». В диффе нет ни юнита, ни смока, который прогоняет
|
||||
`optimizePlans`/`ws_plan_optimize` на фикстуре с существующим нарушением и
|
||||
сравнивает набор нарушений до/после. `grep` по «Optimize»/«optimize» во
|
||||
всех тестовых файлах, тронутых этой задачей, ничего не находит.
|
||||
|
||||
Это не тривиально верно «по построению»: `optimizePlans` вызывает
|
||||
`alignAllToGrid` и `repairNearAxisRoomWalls`, которые двигают узлы к
|
||||
решётке и выпрямляют почти-осевые стены — оба потенциально смещают
|
||||
геометрию на доли сантиметра. Ни один из порогов П1–П5 не имеет запаса
|
||||
шире шага решётки в общем случае (например, узел ровно на 5.0 см от
|
||||
чужой стены после сдвига на привязку может уйти ниже порога). Формально
|
||||
проверить это чтением я не могу (нужно было бы доказать, что снап
|
||||
монотонно не уменьшает ни один из пяти инвариантов, что для П1/П4 не
|
||||
очевидно), поэтому по правилу «либо тест, либо разбор с явной пометкой»
|
||||
AC10 сейчас не доказан никак.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- П1–П5 (`src/junction-limits.ts`) — юнит-покрытие точное, включая границы
|
||||
(14°/15°/16°, 6/7 стен, 19/20/25 см, 4/5 см, пустой/непустой просвет),
|
||||
T-стык как легальная инцидентность, компенсацию перепада толщин (АС3b) —
|
||||
числа владельца (30/20 см → 5 см доборный атом) воспроизведены и на
|
||||
фронте, и на бэке идентично.
|
||||
- §4 (честная вершина легаси) — `isDegenerateApexCorner`,
|
||||
`insetContour`/`outsetContour` дают ровно одну точку в вершине плана и
|
||||
снаружи, и внутри; внешнее кольцо тела — треугольник без ступенек и
|
||||
микровершин; golden-сцена `sharp-apex-legacy-dark` подтверждает это на
|
||||
пиксельном уровне, вся остальная golden-матрица не сдвинулась.
|
||||
- Наследование нарушений считается ПО ПРАВИЛУ (`increasedViolations`), а не
|
||||
по subject id — комментарий и тест прямо объясняют, почему id нестабилен
|
||||
через структурную запись (переатомизация); фронтенд-часть барьера
|
||||
(`_junctionLimitsIntroduced`) верно мигрирует ОБЕ стороны через
|
||||
`commitWallSegmentModel` перед сравнением — это именно то, чего не хватает
|
||||
на бэкенде (H1).
|
||||
- AC7a (Resize) — проверено настоящим pointer-жестом (не вызовом
|
||||
внутренних методов): сетка 2 см, две комнаты в 10 см, тяга 6 см
|
||||
останавливается на 6 см (не доезжает до нарушающих 4 см), ровно один тост
|
||||
с «5». `_rszLimitViolation` сбрасывается в начале КАЖДОЙ проекции —
|
||||
проверил код: без этого сброса тост от предыдущего шага мог бы объяснить
|
||||
отказ другого рода; тест на это (`resize-preview-reject-silent` мутант)
|
||||
ловит немоту, но не проверяет свежесть причины — сам код читаем и
|
||||
корректен (обнуление на первой строке `_rszProjectPreview`).
|
||||
- AC7b («Толщина») — значение не применяется, тост с названием правила,
|
||||
план байт-неизменен (смок).
|
||||
- AC1/AC3/AC5a — смоком, с проверкой, что тост называет конкретное число
|
||||
(не просто безмолвный отказ).
|
||||
- AC2/AC4 — юнитами на обеих сторонах (фронт+бэк), включая T-стык.
|
||||
- AC6 — см. выше, golden + юниты на контурах и кольце.
|
||||
- AC8 (миграция/импорт/restore не блокируются) — проверено ЧТЕНИЕМ, не
|
||||
исполнением: `validate_junction_limits` вызывается только из
|
||||
`ws_config_set`; ни `ws_import_apply`, ни `ws_files_migrate` его не
|
||||
вызывают. На фронте `_junctionLimitsIntroduced` вызывается только из
|
||||
`_commitPhysicalGeometry` и `_rszProjectPreview` — оба это операции
|
||||
редактирования, не импорта/миграции.
|
||||
- AC9 (паритет) — исполнено: `test_parity_with_the_frontend_checks`
|
||||
реально прогоняет TS через `node --eval` и сравнивает с питоновским
|
||||
результатом на 8 фикстурах, 7/7 pass.
|
||||
- Backend exception plumbing в `ws_plan_optimize`: `JunctionLimitError`
|
||||
добавлен в `except`-кортеж, хотя `validate_junction_limits` там не
|
||||
вызывается (сознательно, по §5 — Optimize не должен упираться в новые
|
||||
правила) — код от этого не ломается, исключение просто никогда не
|
||||
возбуждается в этой ветке; не поднимаю до отдельной находки, так как
|
||||
безвредно и не вводит в заблуждение так же сильно, как M1 (нет
|
||||
комментария, утверждающего обратное).
|
||||
- AC5b — формула П5 (`checkRoomClearance`) шейп-агностична (это чистая
|
||||
формула площади по шнурку), поэтому квадратные фикстуры теста
|
||||
математически эквивалентны треугольнику из спека; но буквально
|
||||
«юнит с этими числами» (равносторонний 60/33 см, inradius 17.3/34.6 см)
|
||||
отсутствует — фиксирую как Low, не поднимаю до Medium: код, который
|
||||
доказывал бы обратное, был бы тем же самым.
|
||||
- Трейлеры: `Issue: #329` на каждом коммите; `User-Visible: yes` только там,
|
||||
где меняется видимое поведение (214355f4), и в том же коммите правки
|
||||
обоих changelog — проверено `git show --stat`.
|
||||
- Одно число — один источник: `_junctionLimitLabel` — единственное место,
|
||||
формирующее текст отказа что для тоста рисования/Толщины, что для
|
||||
Resize; `actual`/`limit` берутся из одного и того же объекта
|
||||
`JunctionLimitViolation`, не пересчитываются отдельно для тоста и для
|
||||
ручки — проверено чтением, не вижу второго источника чисел.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- Полный `python -m pytest tests_backend -q` (HA-harness) — недоступен в
|
||||
этой среде (`homeassistant` не установлен, нет `.venv-backend`); прогнан
|
||||
только целевой файл задачи (полностью, 7/7) плюс попытка полного набора
|
||||
зафиксирована как падение импорта, не как «зелёный, но неполный».
|
||||
- Полная браузерная матрица (193 смока) — не прогонял, диф не потребовал
|
||||
этого по `smoke-select` (43 символа, порог 38 не превышен); прогнал все
|
||||
прямые совпадения, зарегистрированную связь и оба AC-смока (15 итого).
|
||||
- Полный `scripts/mutation-gate.mjs` (весь набор мутантов) — несоразмерно
|
||||
ревью; прогнал точечно только 4 мутанта, которые диф добавил/изменил.
|
||||
- `performance_smoke` — не прогонял: в AC не назван, диф не трогает горячий
|
||||
путь рендера (П1–П4 — O(узлы+сегменты) на запись, П5 — на commit, не на
|
||||
каждый кадр курсора, это подтверждается чтением: вызовы
|
||||
`_junctionLimitViolations`/`_junctionLimitsIntroduced` находятся в
|
||||
`_commitPhysicalGeometry` и в `_rszProjectPreview`, оба — не per-frame
|
||||
рендер-путь).
|
||||
- Ручное тестирование в браузере — не проводил (этап code-review, ручного
|
||||
тестирования в цикле нет по регламенту; браузерные смоки — единственная
|
||||
замена).
|
||||
- AC5b буквально «с этими числами» — не поднимаю до отдельного прогона,
|
||||
формула шейп-агностична (см. выше), различие признано как Low и не
|
||||
требует отдельного гейта.
|
||||
|
||||
## Итог
|
||||
|
||||
Один High (H1) — реальный, воспроизведённый исполнением дефект, прямо
|
||||
нарушающий §3 ТЗ и обесценивающий центральное обещание задачи («легаси не
|
||||
блокируется») для бэкенд-пути записи. Плюс четыре Medium в скоупе (M1–M4),
|
||||
все чинятся в этой же задаче без отдельного issue (владелец, #202).
|
||||
Reference in New Issue
Block a user