docs: revise #405 spec after review

Issue: #405
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-01 22:14:26 +03:00
parent bf40e26ed0
commit d5c93d4e28
@@ -1,238 +1,182 @@
# ТЗ #405 — Удаление черновика доказуемо завершается, а порог свидетелей не исчезает вместе с кадрами
# ТЗ #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)
- Приоритет: P2, infra
- Трек: полный — исправление пересекает три модуля (`editor-secondary`,
editor runtime и фасад карточки), поэтому критерий `small` «один модуль» не
выполняется
- Ревизия: 2 (2026-09-01), правки по `SPEC-REVIEW-405-r1`
## Сценарий
Две проверки, которые выглядят работающими и не работают.
Первая: смок утверждает, что удаление незавершённого контура удаляет его
целиком. Утверждение зеленеет только потому, что подтверждение в смоке
резолвится мгновенно; у живого человека подтверждение — это клик, то есть
макрозадача, и к моменту проверки удаление ещё не произошло. Утверждение
ложное, и вместе с ним ложны утверждения ниже по файлу.
Вторая: приёмка скриншотов требует «кадров-свидетелей» — необъявленных кадров,
совпавших байт-в-байт, — чтобы доказать, что съёмка велась в той же среде.
Требование считается от числа уцелевших кадров, поэтому снимается вместе с
ними: `git rm docs/images/*.png` — и доказывать больше нечем, но и не нужно.
Пользователь редактора плана выбирает незавершённый контур, нажимает удаление и
подтверждает опасное действие. Само подтверждение асинхронно: в реальном UI оно
завершается только после отдельного клика. Внешний вызывающий и автоматический
смок должны иметь возможность дождаться всей операции, а не только синхронного
входа в обработчик.
## Что человек увидит до и после
Видимого поведения продукта задача не меняет.
Видимое поведение интерфейса не меняется: кнопка удаления, диалог подтверждения
и результат подтверждённого или отменённого удаления остаются прежними.
**До**: (1) вызывающий не может дождаться удаления черновика — метод синхронный,
а работа асинхронная; смок это скрывает мгновенным стабом; (2) удаление всех
закоммиченных кадров обнуляет порог свидетелей, и тотальная перерисовка
принимается без `--no-witnesses --reason`, то есть без следа в манифесте.
**После**: удаление черновика доказуемо завершено к моменту, когда вызывающий
его дождался; порог свидетелей зависит от набора сценариев, а не от того, что
осталось на диске.
До исправления программный вызывающий получает `undefined` и не может узнать,
когда отложенное удаление завершилось. После исправления он получает промис,
который завершается вместе с подтверждением и транзакцией удаления.
## Проблема и контракты по пунктам
## Проблема
### (1) M2 — оброненный промис в удалении черновика
`_deletePhysicalSelection` объявлен синхронным. Для выбранного draft он вызывает
асинхронный `_deleteDraftWhole()`, отбрасывает возвращённый промис и немедленно
возвращает управление. `demo/smoke_free_walls.mjs` пишет
`await c._deletePhysicalSelection()`, но фактически ждёт `undefined`.
Цепочка синхронна на всём протяжении, а конец её асинхронный:
Текущий стаб `_confirmDanger = async () => true` резолвится в ближайшей
микрозадаче, поэтому гонка обычно скрыта. Если подтверждение резолвить через
макрозадачу, как реальный пользовательский клик, проверка выполняется до
удаления; незавершённый контур остаётся в состоянии и загрязняет последующие
утверждения смока.
| Место | Сигнатура |
|---|---|
| `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`) резолвится в том же
сливе микрозадач.
Номера строк ориентировочные и относятся к `dev` на момент ревизии 2.
**Воспроизведено исполнением**: единственная правка копии смока — подтверждение
резолвится через макрозадачу, как настоящий клик:
| Место | Текущее поведение | Требуемое поведение |
|---|---|---|
| `src/editor-secondary.ts:221` `runContext` | принимает `() => void`, отбрасывает результат | обобщённо возвращает результат действия или `undefined`, если context уже неактуален |
| `src/houseplan-editor-runtime.ts:5290` `_runEditorContext` | принимает `() => void`, отбрасывает результат `runContext` | обобщённо пробрасывает результат `runContext` |
| `src/houseplan-card.ts:8929` `_runEditorContext` | фасад типизирован как `void` | зеркалит обобщённый тип runtime и пробрасывает результат |
| `src/houseplan-editor-runtime.ts:3073` `_deletePhysicalSelection` | `(): void`, draft-ветка вызывает `_deleteDraftWhole()` без `return`/`await` | всегда возвращает `Promise<void>`, draft-ветка дожидается `_deleteDraftWhole()` |
| `src/houseplan-card.ts:7897` `_deletePhysicalSelection` | фасад типизирован как `(): void` | фасад типизирован как `(): Promise<void>` и возвращает промис runtime |
| `src/houseplan-editor-runtime.ts:3119` `_deleteDraftWhole` | уже `async (): Promise<void>` | без изменения контракта |
```js
c._confirmDanger = () => new Promise((r) => setTimeout(() => r(true), 0));
```
Фасад `src/houseplan-card.ts:7897` обязателен в правке: именно экземпляр
`houseplan-card` находится в `window.__card` и вызывается смоком. Если runtime
станет `Promise<void>`, а фасад останется `(): void`, `tsc --noEmit` получит
`TS2322`. Соседние фасады `_deleteDraftWhole` и `_deleteDraftSegment` уже
следуют требуемому паттерну `(): Promise<void>`.
```
"deleteRemovesPartition": true,
"deleteOnDraftRemovesWholeOutline": false,
FAILED (2):
- deleteOnDraftRemovesWholeOutline: expected true, got false
- invalidThicknessCreatesNothing: expected true, got false
```
## Контракт поведения
Второй провал важнее первого: оброненный промис отравляет и последующие
проверки — состояние на момент их выполнения не то, которое смок считает
подготовленным. Цена дефекта не «одна проверка без доказательства», а «дальше
по файлу доказательства условны».
1. `_deletePhysicalSelection()` всегда возвращает `Promise<void>` во всех
ветках, включая синхронное удаление partition/column и ранний выход без
выбора.
2. Для draft промис завершается только после решения пользователя и, при
подтверждении, после фиксации транзакции удаления.
3. Отмена подтверждения резолвит промис без удаления и без отклонения.
4. `runContext` и оба фасада не обрывают возвращаемое действие. При устаревшем
context действие не запускается, возвращается `undefined`.
5. UI-обработчики вправе игнорировать промис: кнопки не блокируются, новый
индикатор ожидания не появляется.
6. Синхронные ветки удаления сохраняют существующее поведение, но оборачивают
завершение в уже разрешённый промис.
**Контракт**: асинхронная операция обязана быть дождавшейся — то есть
возвращать промис до самого внешнего вызывающего, который захочет её дождаться.
UI ждать не обязан (кнопка не блокируется, поведение для человека не меняется),
но возможность дождаться должна существовать, иначе проверить операцию нечем.
## Скоуп
Практически: `_deletePhysicalSelection` возвращает `Promise<void>` и
пробрасывает промис `_deleteDraftWhole`; `_runEditorContext` и `runContext`
возвращают результат действия вместо `void` (обобщённый параметр), чтобы
промис не терялся на полпути. Обработчики кнопок остаются как есть — они
результат игнорируют.
- типы и возврат значения по цепочке `runContext` → `_runEditorContext`;
- runtime- и card-фасады `_deletePhysicalSelection`;
- задержанный стаб подтверждения и утверждения удаления/отмены в
`demo/smoke_free_walls.mjs`;
- отрицательный мутант, доказывающий, что смок ловит отброшенный промис.
**Связка с #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 ожидания;
- изменение диалога опасного подтверждения;
- другие асинхронные операции редактора и `_deleteDraftSegment`;
- общий гард `unhandledrejection` (#404);
- docs witness floor и сохранение `acceptance.declared`: исправлены отдельно в
#409, коммит `8119c523`;
- аналогичный golden witness floor: #408.
## UX
Видимых изменений нет. Кнопка удаления работает как прежде и по-прежнему не
ждёт завершения записи.
UI не меняется. Подтверждение, отмена, фокус и доступность диалога сохраняются.
Touch-контракт не затронут: не добавляются жесты, цели касания или новые
состояния кнопок.
## Модель данных и миграция
Не применимо. Формат конфига и формат манифеста скриншотов не меняются:
`acceptance.floor` остаётся числом, меняется только способ его вычисления.
Конфиг, сохранённая геометрия и compatibility-поля не меняются. Миграция не
нужна.
## i18n
Новых строк нет.
Новых и изменённых строк нет.
## Производительность
Влияния нет: новая асинхронная работа не добавляется, наружу пробрасывается уже
существующий промис. UI продолжает игнорировать его там, где ожидание не нужно.
## Критерии приёмки
- **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="…"` остаётся единственным законным обходом
и по-прежнему требует непустой причины, которая попадает в манифест.
Доказательство: существующий тест обхода зелёный.
- **AC1.** При подтверждении, которое резолвится через `setTimeout(..., 0)`,
`await window.__card._deletePhysicalSelection()` возвращает управление только
после полного удаления draft. Доказательство: `demo/smoke_free_walls.mjs`.
- **AC2.** Тот же смок умеет падать: мутант, который снова отбрасывает промис
`_deleteDraftWhole`, получает
`deleteOnDraftRemovesWholeOutline: false`. Доказательство:
`scripts/mutation-gate.mjs`.
- **AC3.** При задержанном решении `false` ожидание завершается, draft остаётся,
новая транзакция истории не появляется. Доказательство:
`demo/smoke_free_walls.mjs`.
- **AC4.** Удаление partition и column, отсутствие выбора и ветка partition с
проёмами сохраняют прежний результат, но возвращают `Promise<void>`.
Доказательство: существующие и дополненные проверки
`demo/smoke_free_walls.mjs`, плюс `tsc --noEmit` для типов.
- **AC5.** Возврат действия не теряется в `runContext`, обоих
`_runEditorContext` и card-фасаде `_deletePhysicalSelection`; устаревший
context по-прежнему не запускает действие. Доказательство: unit-тест
`EditorSecondaryController.runContext` и `tsc --noEmit`.
- **AC6.** Визуальное поведение кнопок не меняется и UI не ждёт промис.
Доказательство: существующие смоки редактора зелёные без изменения их
пользовательских утверждений; ревью кода обработчиков `@click`.
## План автотестов
**Смок** (`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 краснеет.
1. В `demo/smoke_free_walls.mjs` заменить мгновенный стаб подтверждения на
управляемый задержанный стаб и отдельно проверить подтверждение и отмену.
2. Сохранить прямой вызов публичного тестового фасада `window.__card`, чтобы
проверка проходила через `src/houseplan-card.ts`, а не только через runtime.
3. Добавить unit-проверку `runContext`: актуальный context возвращает как
синхронное значение, так и промис; устаревший context не вызывает action и
возвращает `undefined`.
4. Добавить мутант `draft-delete-drops-the-promise`, который восстанавливает
потерю промиса и обязан краснить целевой смок.
5. Прогнать `npx tsc --noEmit`, `npm test`, `npm run build`, выбранные
`smoke-select` смоки и `node scripts/no-new-any.mjs`.
## Риски
- **Возврат промиса меняет типы у трёх функций сразу.** `runContext` вызывается
не только из удаления. Смягчение: возвращаемый тип обобщается, существующие
вызывающие результат игнорируют — для них ничего не меняется; `typecheck`
доказывает, что никто не сломан.
- **Ожидание промиса в смоке может замаскировать гонку, а не убрать её.**
Смягчение: AC2 требует, чтобы на неисправленном коде смок краснел — то есть
доказывает, что проверка меряет именно ожидание.
- **Ужесточение порога сломает первую приёмку на чистом клоне.** Смягчение:
следствие названо явно (`--no-witnesses --reason`), сообщение об отказе уже
подсказывает эту дорогу дословно.
- **Изменение порога затрагивает golden.** `goldenWitnessFloor` намеренно
одинаков с `docsWitnessFloor` (комментарий `:33`). Смягчение: правится
источник числа, а не формула; если правка формулы окажется неизбежной — она
зеркалится в golden в той же задаче, иначе два набора получат разные правила,
чего комментарий и запрещает.
- Обобщение `runContext` затрагивает все его вызовы. Смягчение: для действий,
возвращающих `void`, выведенный контракт остаётся `void | undefined`; типы и
существующие смоки доказывают отсутствие изменения поведения.
- Асинхронный `_deletePhysicalSelection` превращает синхронные исключения его
тела в отклонение промиса. UI промис игнорирует, поэтому не расширяем скоуп до
общего error-handling; #404 отвечает за наблюдаемость отклонений в смоках.
- Слишком быстрый стаб снова скроет дефект. Поэтому AC1 и AC2 требуют
макрозадачу и отрицательный мутант, а не только зелёный штатный прогон.
## Откат
Обе половины локальны: тип возвращаемого значения у трёх функций и один
аргумент в вычислении порога. Данные пользователя и формат манифеста не
затрагиваются.
Откат локален: вернуть синхронные сигнатуры и прежний стаб смока. Данные и
конфиг пользователя не требуют обратной миграции.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: **не требуется**
(User-Visible: no) — видимого поведения задача не меняет.
- Скриншоты не меняются.
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: не требуются
(`User-Visible: no`), так как UI и пользовательский контракт не меняются;
- документация пользователя, golden, скриншоты, performance- и
security-артефакты не меняются.
## Принятые технические предположения
- Для проброса результата используется generic `T`, а не перечисление
`void | Promise<void>`: это сохраняет точный тип каждого action и не создаёт
отдельного async-контракта у нейтрального context-гварда.
- `_deletePhysicalSelection` имеет единый `Promise<void>` во всех ветках, а не
union `void | Promise<void>`, чтобы вызывающий всегда мог безопасно ждать
завершения без анализа выбранного типа геометрии.
- Ссылки на строки ориентировочны; исполнение определяется символами и
контрактами из таблицы, если `dev` сдвинет номера строк перед реализацией.