From 2c6d937d468e99799f5f5b78d312e2f79fefda3b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 20:01:20 +0300 Subject: [PATCH] docs: specify draft delete awaiting and witness floor (#405) User-Visible: no Issue: #405 --- .../405-dropped-promise-and-witness-floor.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/specs/405-dropped-promise-and-witness-floor.md diff --git a/docs/specs/405-dropped-promise-and-witness-floor.md b/docs/specs/405-dropped-promise-and-witness-floor.md new file mode 100644 index 00000000..ac9608fe --- /dev/null +++ b/docs/specs/405-dropped-promise-and-witness-floor.md @@ -0,0 +1,238 @@ +# ТЗ #405 — Удаление черновика доказуемо завершается, а порог свидетелей не исчезает вместе с кадрами + +- Issue: https://github.com/Matysh/houseplan-card/issues/405 +- Приоритет: P2, infra; полный трек — две несвязанные поверхности (транзакция + геометрии в `src/**` и приёмка скриншотов в `scripts/**`), критерий «одна + поверхность» для `small` не выполняется. Класс файлов A + B: правка + `src/houseplan-editor-runtime.ts` делает задачу продуктовой по §1 +- Ревизия: 1 (2026-08-31) + +## Сценарий + +Две проверки, которые выглядят работающими и не работают. + +Первая: смок утверждает, что удаление незавершённого контура удаляет его +целиком. Утверждение зеленеет только потому, что подтверждение в смоке +резолвится мгновенно; у живого человека подтверждение — это клик, то есть +макрозадача, и к моменту проверки удаление ещё не произошло. Утверждение +ложное, и вместе с ним ложны утверждения ниже по файлу. + +Вторая: приёмка скриншотов требует «кадров-свидетелей» — необъявленных кадров, +совпавших байт-в-байт, — чтобы доказать, что съёмка велась в той же среде. +Требование считается от числа уцелевших кадров, поэтому снимается вместе с +ними: `git rm docs/images/*.png` — и доказывать больше нечем, но и не нужно. + +## Что человек увидит до и после + +Видимого поведения продукта задача не меняет. + +**До**: (1) вызывающий не может дождаться удаления черновика — метод синхронный, +а работа асинхронная; смок это скрывает мгновенным стабом; (2) удаление всех +закоммиченных кадров обнуляет порог свидетелей, и тотальная перерисовка +принимается без `--no-witnesses --reason`, то есть без следа в манифесте. +**После**: удаление черновика доказуемо завершено к моменту, когда вызывающий +его дождался; порог свидетелей зависит от набора сценариев, а не от того, что +осталось на диске. + +## Проблема и контракты по пунктам + +### (1) M2 — оброненный промис в удалении черновика + +Цепочка синхронна на всём протяжении, а конец её асинхронный: + +| Место | Сигнатура | +|---|---| +| `editor-secondary.ts:221` | `runContext(captured, current, action: () => void): void` | +| `houseplan-editor-runtime.ts:5290` | `_runEditorContext(contextId, action: () => void): void` | +| `houseplan-editor-runtime.ts:3073` | `_deletePhysicalSelection = (): void =>` | +| `houseplan-editor-runtime.ts:3077` | `if (sel.kind === 'draft') { this._deleteDraftWhole(); return; }` ← промис отброшен | +| `houseplan-editor-runtime.ts:3119` | `_deleteDraftWhole = async (): Promise =>` — внутри `await _confirmDanger(...)` | + +`demo/smoke_free_walls.mjs:194,199` пишет `await c._deletePhysicalSelection()`, +но ждёт `undefined` — ровно один микротаск. Зеленеет проверка только потому, +что стаб на `:16` (`c._confirmDanger = async () => true`) резолвится в том же +сливе микрозадач. + +**Воспроизведено исполнением**: единственная правка копии смока — подтверждение +резолвится через макрозадачу, как настоящий клик: + +```js +c._confirmDanger = () => new Promise((r) => setTimeout(() => r(true), 0)); +``` + +``` +"deleteRemovesPartition": true, +"deleteOnDraftRemovesWholeOutline": false, +FAILED (2): + - deleteOnDraftRemovesWholeOutline: expected true, got false + - invalidThicknessCreatesNothing: expected true, got false +``` + +Второй провал важнее первого: оброненный промис отравляет и последующие +проверки — состояние на момент их выполнения не то, которое смок считает +подготовленным. Цена дефекта не «одна проверка без доказательства», а «дальше +по файлу доказательства условны». + +**Контракт**: асинхронная операция обязана быть дождавшейся — то есть +возвращать промис до самого внешнего вызывающего, который захочет её дождаться. +UI ждать не обязан (кнопка не блокируется, поведение для человека не меняется), +но возможность дождаться должна существовать, иначе проверить операцию нечем. + +Практически: `_deletePhysicalSelection` возвращает `Promise` и +пробрасывает промис `_deleteDraftWhole`; `_runEditorContext` и `runContext` +возвращают результат действия вместо `void` (обобщённый параметр), чтобы +промис не терялся на полпути. Обработчики кнопок остаются как есть — они +результат игнорируют. + +**Связка с #404**: если `_deleteDraftWhole` когда-нибудь отклонится, это станет +`unhandledrejection`, и его ловит тот же гард смоков — с той же слепотой к +хвосту, что чинит #404. Задачи независимы, но обе нужны, чтобы отказ удаления +не исчезал бесследно. + +### (2) M4 — порог свидетелей обнуляется вместе с кадрами + +`scripts/docs-accept.mjs:117-123` заполняет `committed` только по файлам, +лежащим на диске: + +```js +const onDisk = resolve(ROOT, 'docs/images', entry.file); +if (existsSync(onDisk)) committed[scenario.id] = sha256(readFileSync(onDisk)); +``` + +`docs-acceptance.mjs:113` считает `floor = docsWitnessFloor(withCommitted.length)`, +а формула на `:44` при нуле даёт ноль. + +**Воспроизведено** модельным прогоном чистой функции — контрастная пара на одном +наборе из десяти сцен и одной декларации «изменились все десять»: + +``` +1) PNG на месте: floor=0 свидетелей=0 → отказ: «свидетелей 0 из необходимых 1» +2) PNG удалены: floor=0 свидетелей=0 → отказа НЕТ, принимаются все 10 +``` + +Щель не требует хака: `git rm docs/images/*.png` плюс `--expect-change` на все +десять. Предшествующая проверка `strayed` не мешает — при пустом `committed` +каждый кадр «разошёлся», но все они объявлены. + +Порог задумывался (комментарий `docs-acceptance.mjs:33-43`) закрыть ровно +«попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся +вовсе». Именно этот случай он и пропускает, потому что считает от числа +уцелевших кадров. + +**Контракт**: порог считается от размера набора сценариев — величины, известной +из `DOC_SCREENSHOTS` и не зависящей от содержимого диска. Отсутствие +закоммиченного кадра — не смягчающее обстоятельство, а само по себе повод +требовать явного обхода: `--no-witnesses --reason="…"` остаётся единственной +законной дорогой, и причина остаётся в манифесте. + +**Осознанное следствие**: первая приёмка набора, у которого кадров ещё нет +(новый сценарий, чистый клон без `docs/images`), потребует +`--no-witnesses --reason`. Это правильно: доказать среду в такой ситуации +нечем, и единственное, что можно сделать честно, — оставить письменную причину. + +## Скоуп / не-скоуп + +**В скоупе**: возврат промиса по цепочке `runContext` → +`_runEditorContext` → `_deletePhysicalSelection`; стаб подтверждения и +ожидание в `demo/smoke_free_walls.mjs`; формула и источник порога в +`scripts/docs-acceptance.mjs`; смоки, тесты и мутанты. + +**Не в скоупе**: остальные асинхронные операции редактора (`_deleteDraftSegment` +и прочие имеют своих вызывающих и свои смоки — если у них тот же узор, это +новый issue, а не попутная правка); блокировка кнопки на время удаления — +это UX-решение, которого задача не обещает; гард uncaught exception (#404); +затирание `acceptance.declared` при повторной приёмке (#406 «д») — соседняя +строка того же файла, но другой контракт. + +## UX + +Видимых изменений нет. Кнопка удаления работает как прежде и по-прежнему не +ждёт завершения записи. + +## Модель данных и миграция + +Не применимо. Формат конфига и формат манифеста скриншотов не меняются: +`acceptance.floor` остаётся числом, меняется только способ его вычисления. + +## i18n + +Новых строк нет. + +## Критерии приёмки + +- **AC1**. Удаление черновика доказуемо завершено к моменту, когда вызывающий + дождался возвращённого промиса, **при подтверждении, резолвящемся через + макрозадачу**. Доказательство: `smoke_free_walls` со стабом + `() => new Promise((r) => setTimeout(() => r(true), 0))`. +- **AC2**. **Отрицательный прогон обязателен**: на неисправленном коде тот же + смок с тем же стабом краснеет (`deleteOnDraftRemovesWholeOutline: false`). + Доказательство: мутант через штатный раннер `scripts/mutation-gate.mjs`. +- **AC3**. Отказ подтверждения не удаляет черновик, и вызывающий это узнаёт: + промис резолвится, контур на месте. Доказательство: та же пара проверок со + стабом, возвращающим `false`. +- **AC4**. Поведение кнопок не изменилось: обработчик не ждёт промис, интерфейс + не блокируется. Доказательство: существующие смоки редактора зелёные без + правок их утверждений. +- **AC5**. Удаление перегородки и колонны (синхронные ветки того же метода) + работают как прежде. Доказательство: `deleteRemovesPartition` и соседние + проверки `smoke_free_walls` зелёные. +- **AC6**. Порог свидетелей не зависит от наличия файлов на диске: удаление + всех закоммиченных кадров и объявление всех изменёнными **отказывает**. + Доказательство: тест на чистой функции с `committed: {}`. +- **AC7**. Штатная приёмка не изменилась: один изменённый кадр из десяти, + объявленный `--expect-change`, принимается, девять свидетелей засчитаны. + Доказательство: существующие тесты `test/docs-acceptance.test.mjs` зелёные + без правок их утверждений. +- **AC8**. `--no-witnesses --reason="…"` остаётся единственным законным обходом + и по-прежнему требует непустой причины, которая попадает в манифест. + Доказательство: существующий тест обхода зелёный. + +## План автотестов + +**Смок** (`demo/smoke_free_walls.mjs`, правка существующего): + +1. Стаб подтверждения переводится на макрозадачу (AC1, AC3). +2. Ожидание становится настоящим — `await` на возвращённом промисе. +3. Проверка отказа подтверждения добавляется рядом (AC3). + +**Тесты** (`test/docs-acceptance.test.mjs`, дополнение): + +4. `committed: {}` + все объявлены → отказ с упоминанием свидетелей (AC6). +5. Порог при полном наборе равен прежнему — 1 из 10 (AC7). + +**Мутанты** (`scripts/mutation-gate.mjs`): + +- `draft-delete-drops-the-promise`: вернуть `_deletePhysicalSelection` к `void` + без проброса → смок AC1 краснеет; +- `witness-floor-counts-survivors`: вернуть счёт порога от числа уцелевших + кадров → тест AC6 краснеет. + +## Риски + +- **Возврат промиса меняет типы у трёх функций сразу.** `runContext` вызывается + не только из удаления. Смягчение: возвращаемый тип обобщается, существующие + вызывающие результат игнорируют — для них ничего не меняется; `typecheck` + доказывает, что никто не сломан. +- **Ожидание промиса в смоке может замаскировать гонку, а не убрать её.** + Смягчение: AC2 требует, чтобы на неисправленном коде смок краснел — то есть + доказывает, что проверка меряет именно ожидание. +- **Ужесточение порога сломает первую приёмку на чистом клоне.** Смягчение: + следствие названо явно (`--no-witnesses --reason`), сообщение об отказе уже + подсказывает эту дорогу дословно. +- **Изменение порога затрагивает golden.** `goldenWitnessFloor` намеренно + одинаков с `docsWitnessFloor` (комментарий `:33`). Смягчение: правится + источник числа, а не формула; если правка формулы окажется неизбежной — она + зеркалится в golden в той же задаче, иначе два набора получат разные правила, + чего комментарий и запрещает. + +## Откат + +Обе половины локальны: тип возвращаемого значения у трёх функций и один +аргумент в вычислении порога. Данные пользователя и формат манифеста не +затрагиваются. + +## Release-артефакты + +- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: **не требуется** + (User-Visible: no) — видимого поведения задача не меняет. +- Скриншоты не меняются.