docs: specify draft delete awaiting and witness floor (#405)

User-Visible: no
Issue: #405
This commit is contained in:
Codex
2026-09-01 22:12:13 +03:00
committed by Sergey Matyunin
parent 9c9a22354b
commit 2c6d937d46
@@ -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<void> =>` — внутри `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<void>` и
пробрасывает промис `_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) — видимого поведения задача не меняет.
- Скриншоты не меняются.