mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
docs: specify draft delete awaiting and witness floor (#405)
User-Visible: no Issue: #405
This commit is contained in:
@@ -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) — видимого поведения задача не меняет.
|
||||
- Скриншоты не меняются.
|
||||
Reference in New Issue
Block a user