diff --git a/docs/specs/405-dropped-promise-and-witness-floor.md b/docs/specs/405-dropped-promise-and-witness-floor.md index ac9608fe..89dc318c 100644 --- a/docs/specs/405-dropped-promise-and-witness-floor.md +++ b/docs/specs/405-dropped-promise-and-witness-floor.md @@ -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 =>` — внутри `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`, draft-ветка дожидается `_deleteDraftWhole()` | +| `src/houseplan-card.ts:7897` `_deletePhysicalSelection` | фасад типизирован как `(): void` | фасад типизирован как `(): Promise` и возвращает промис runtime | +| `src/houseplan-editor-runtime.ts:3119` `_deleteDraftWhole` | уже `async (): Promise` | без изменения контракта | -```js -c._confirmDanger = () => new Promise((r) => setTimeout(() => r(true), 0)); -``` +Фасад `src/houseplan-card.ts:7897` обязателен в правке: именно экземпляр +`houseplan-card` находится в `window.__card` и вызывается смоком. Если runtime +станет `Promise`, а фасад останется `(): void`, `tsc --noEmit` получит +`TS2322`. Соседние фасады `_deleteDraftWhole` и `_deleteDraftSegment` уже +следуют требуемому паттерну `(): Promise`. -``` -"deleteRemovesPartition": true, -"deleteOnDraftRemovesWholeOutline": false, -FAILED (2): - - deleteOnDraftRemovesWholeOutline: expected true, got false - - invalidThicknessCreatesNothing: expected true, got false -``` +## Контракт поведения -Второй провал важнее первого: оброненный промис отравляет и последующие -проверки — состояние на момент их выполнения не то, которое смок считает -подготовленным. Цена дефекта не «одна проверка без доказательства», а «дальше -по файлу доказательства условны». +1. `_deletePhysicalSelection()` всегда возвращает `Promise` во всех + ветках, включая синхронное удаление partition/column и ранний выход без + выбора. +2. Для draft промис завершается только после решения пользователя и, при + подтверждении, после фиксации транзакции удаления. +3. Отмена подтверждения резолвит промис без удаления и без отклонения. +4. `runContext` и оба фасада не обрывают возвращаемое действие. При устаревшем + context действие не запускается, возвращается `undefined`. +5. UI-обработчики вправе игнорировать промис: кнопки не блокируются, новый + индикатор ожидания не появляется. +6. Синхронные ветки удаления сохраняют существующее поведение, но оборачивают + завершение в уже разрешённый промис. -**Контракт**: асинхронная операция обязана быть дождавшейся — то есть -возвращать промис до самого внешнего вызывающего, который захочет её дождаться. -UI ждать не обязан (кнопка не блокируется, поведение для человека не меняется), -но возможность дождаться должна существовать, иначе проверить операцию нечем. +## Скоуп -Практически: `_deletePhysicalSelection` возвращает `Promise` и -пробрасывает промис `_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`. + Доказательство: существующие и дополненные проверки + `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`: это сохраняет точный тип каждого action и не создаёт + отдельного async-контракта у нейтрального context-гварда. +- `_deletePhysicalSelection` имеет единый `Promise` во всех ветках, а не + union `void | Promise`, чтобы вызывающий всегда мог безопасно ждать + завершения без анализа выбранного типа геометрии. +- Ссылки на строки ориентировочны; исполнение определяется символами и + контрактами из таблицы, если `dev` сдвинет номера строк перед реализацией.