diff --git a/docs/specs/421-negative-test-proofs.md b/docs/specs/421-negative-test-proofs.md new file mode 100644 index 00000000..1a6b112c --- /dev/null +++ b/docs/specs/421-negative-test-proofs.md @@ -0,0 +1,264 @@ +# ТЗ #421 — Отрицательные доказательства для трёх защитных проверок + +- Issue: https://github.com/Matysh/houseplan-card/issues/421 +- Приоритет: P2, tech-debt/tests/infra +- Маршрут: standard; три независимые поверхности нарушают критерий `small` +- Связанные контракты: #43 (support preview), #404/#407 (browser error + guard), #401/#409 (приёмка документационных скриншотов), #85 (mutation gate) + +## Цель + +Сделать три существующих утверждения доказательными: тест или гейт обязан +краснеть, когда ломается ровно тот механизм, защитой которого он объявлен. +Пользовательское поведение не меняется; закрепляются уже действующие контракты. + +## Проблема + +### 1. Lifecycle support-preview + +`test_support_preview_replacement_and_discard_are_draft_local` создаёт три +разных токена, а затем отправляет три идемпотентных `discard`. Успех `discard` +ничего не говорит о наличии токена в runtime-map. Поэтому тест остаётся зелёным, +если убрать replacement-loop из `ws_support_preview()`. + +Отдельно не доказано истечение `SUPPORT_PREVIEW_TTL_S`: ни один тест не +переводит monotonic clock за границу TTL и не пытается применить истёкший токен. + +### 2. `reportPageErrors()` + +Browser probes `guard_tail_exception`, `guard_tail_rejection` и +`guard_closed_page` завершаются через `finish()`. Существующие мутанты ломают +round-trip внутри `finish()` либо общий registry страниц, но не отдельный +round-trip в `reportPageErrors()`. Удаление этого `await` остаётся зелёным. + +### 3. Acceptance trace документационных скриншотов + +Чистые тесты `docsAcceptancePlan()` проверяют выбор replace/keep/witnesses, но +не ветку в `docs-accept.mjs`, которая при fingerprint-only refresh переносит +предыдущий `manifest.acceptance`. Возврат к пустому следу приёмки не замечается. + +## Скоуп + +В скоупе: + +- реальные submit-проверки заменённого и явно удалённого preview-токена; +- доказательство, что токен другого `draft_id` не инвалидируется заменой; +- детерминированный TTL-тест без ожидания реального времени; +- отдельная browser probe, использующая `reportPageErrors()`, а не `finish()`; +- адресный мутант, удаляющий round-trip только из `reportPageErrors()`; +- исполняемая, экспортируемая логика построения нового manifest acceptance + внутри `docs-accept.mjs` и её unit-тест; +- адресный мутант, возвращающий потерю прежнего acceptance trace; +- запись отрицательных доказательств в mutation registry и документ ревью. + +## Не-скоуп + +- изменение WebSocket API, кодов ошибок, TTL, лимитов или idempotency semantics; +- изменение идемпотентного ответа `preview/discard` для отсутствующего токена; +- изменение UI Help & feedback или текста privacy notice; +- переработка общего browser smoke harness и всех существующих probes; +- изменение правил принятия скриншотов, witness floor или CLI-флагов; +- новый общий процессный документ: требование показать, что тест умеет падать, + уже закреплено в `AGENTS.md` и `PROCESS.md`; +- полный аудит всех тестов проекта за пределами трёх названных швов. + +## Контракт + +### 1. Replacement, discard и TTL preview-токена + +Для одного HA user и одного `draft_id` новый preview заменяет предыдущий: + +- submit со старым токеном отвечает `support_preview_expired`; +- submit с новым токеном остаётся доступным, пока токен не удалён, не истёк и не + был успешно использован; +- preview другого `draft_id` того же user заменой не затрагивается и может быть + успешно отправлен; +- после успешного `preview/discard` submit того же токена отвечает + `support_preview_expired`; +- сам `discard` остаётся идемпотентным и по-прежнему может ответить `{ok: true}` + для уже отсутствующего токена. + +TTL проверяется управляемым monotonic clock: + +- до `expires` токен пригоден; +- при `now >= expires` следующий submit вызывает pruning и отвечает + `support_preview_expired`; +- relay transport для отклонённого токена не вызывается; +- тест не использует `sleep` и не зависит от скорости CI. + +Тесты используют разные idempotency keys, чтобы результат lifecycle токена не +маскировался relay/idempotency-кэшем. + +### 2. Отдельный отрицательный путь `reportPageErrors()` + +Новая browser probe: + +1. запускает настоящий Chromium через существующий harness; +2. регистрирует страницу штатным `watchPage`/`launch` путём; +3. создаёт page error в хвосте текущего browser turn; +4. завершает сценарий только через `await reportPageErrors()`; +5. ожидает ненулевой exit code и сообщение + `uncaught exception(s) inside the card`. + +Probe не вызывает `finish()`: иначе она снова доказывала бы чужую ветку. +`verify-guard.mjs` запускает probe рядом с существующими и отличает ожидаемый +красный вердикт от timeout/crash без диагностического сообщения. + +Mutation registry содержит адресный патч, который удаляет +`await roundTripLivePages()` только из тела `reportPageErrors()`. Патч обязан +иметь уникальный anchor и краснеть на новой probe. + +### 3. Сохранение acceptance trace + +CLI-owned функция построения принятого manifest получает: + +- candidate manifest; +- прежний `acceptance` либо отсутствие значения; +- решение `docsAcceptancePlan()`; +- флаг/причину сознательного пропуска witnesses. + +Она возвращает новый manifest без мутации входов: + +- если `decision.replace` непуст, записывает новый trace с точными + `declared`, `witnesses`, `floor` и, при необходимости, + `witnessesSkippedBecause`; +- если пиксели не заменялись, сохраняет все поля прежнего trace и добавляет + `lastWriteWasFingerprintOnly: true`; +- если прежнего trace нет, fingerprint-only refresh создаёт только + `lastWriteWasFingerprintOnly: true`, не выдумывая witnesses; +- `main()` использует эту функцию как единственный путь формирования поля + `acceptance` перед записью `docs/images/screenshots.json`. + +Unit-тест живёт рядом с тестами `docs-accept.mjs`, а не только в тестах чистого +планировщика. Mutation registry возвращает старый пустой fallback и обязан +получить красный результат именно от этого теста. + +## Затрагиваемые файлы + +Ожидаемый набор: + +- `tests_backend/test_ha_websocket.py` — submit-проверки replacement/discard и + отдельный TTL case; +- `demo/guard/guard_report_page_errors.mjs` — новая отрицательная browser probe; +- `demo/guard/verify-guard.mjs` — запуск и проверка новой probe; +- `scripts/docs-accept.mjs` — тестируемая CLI-owned сборка acceptance trace; +- `test/docs-accept.test.mjs` — точная матрица нового/сохранённого trace; +- `scripts/mutation-gate.mjs` — адресные мутанты трёх разрывов; +- `test/mutation-gate.test.mjs` — существующая применимость реестра; новые + специальные тесты нужны только если текущий общий контракт их не покрывает; +- `docs/TESTING.md` — только если без короткого описания новых guard IDs их + невозможно обнаружить из действующего runbook. + +Production modules `custom_components/houseplan/websocket_api.py` и +`demo/serve.mjs` менять не требуется, если новые доказательства подтверждают +текущее поведение. Если тест выявит реальный дефект поведения, реализация +останавливается и найденный продуктовый scope оформляется отдельно. + +## i18n, compatibility, touch и security + +- новых строк и изменений словарей нет; +- persisted config, import/export и WebSocket schema не меняются; +- desktop/touch/kiosk/accessibility не меняются; +- новых входов, прав, URL и раскрытия данных нет; +- тесты support preview не должны писать raw package/message в failure output; +- временные mutation worktrees удаляются существующим cleanup-контрактом. + +## Производительность + +Обычный `npm test` получает только дешёвые unit/registry проверки. HA integration +test выполняется в закреплённом backend job. Новая browser probe и адресные +мутанты входят в дорогой mutation/release gate и не запускаются на каждом +обычном frontend unit прогоне сверх уже существующего `verify-guard` там, где +он явно вызван. + +Никаких runtime-издержек продукта нет. Реальные `sleep` запрещены; TTL управляет +mocked monotonic clock. + +## Критерии приёмки + +- **AC1.** После replacement submit старого токена получает + `support_preview_expired`, а submit токена другого `draft_id` достигает + подменённого transport с точными preview bytes. Доказательство: HA backend + test + отрицательный прогон без replacement-loop. +- **AC2.** После `preview/discard` submit удалённого токена получает + `support_preview_expired`; идемпотентный success самого discard не используется + как доказательство удаления. Доказательство: HA backend test + отрицательный + прогон без удаления записи. +- **AC3.** Токен принимается до TTL и отклоняется при `now >= expires`, transport + после истечения не вызывается; тест не ждёт реальное время. Доказательство: + HA backend test + отрицательный прогон без submit-pruning. +- **AC4.** Новая browser probe завершается ожидаемым failure и сообщением через + `reportPageErrors()` без вызова `finish()`. Доказательство: + `node demo/guard/verify-guard.mjs`. +- **AC5.** Мутант, удаляющий round-trip только из `reportPageErrors()`, оставляет + probe без требуемого вердикта и mutation gate считает мутанта пойманным. + Доказательство: адресный запуск `scripts/mutation-gate.mjs --id=...`. +- **AC6.** При замене пикселей новый acceptance trace точен; при + fingerprint-only refresh прежние `declared`, `witnesses`, `floor` и + произвольные совместимые поля сохраняются, выставляется + `lastWriteWasFingerprintOnly`. Входы не мутируются. Доказательство: unit. +- **AC7.** Мутант, возвращающий пустой fingerprint-only trace, краснеет на + тесте CLI-owned функции, а не на тесте `docsAcceptancePlan()`. + Доказательство: адресный запуск mutation gate. +- **AC8.** `node scripts/mutation-gate.mjs --check`, typecheck, полный frontend + unit suite, build и затронутые backend tests зелёные. Доказательство: локальные + гейты и Linux CI. +- **AC9.** Runtime product behavior, публичные API, i18n, config schema и + пиксели не изменены. Доказательство: diff/code review; smoke/golden/docs + screenshots не требуются при отсутствии product-source visual delta. + +## План отрицательной проверки + +До реализации или в review evidence фиксируются три независимых красных +результата: + +1. удалён replacement-loop (и отдельно pruning/discard для AC2–AC3) — HA-тесты + падают на ожидаемом коде/transport call; +2. удалён round-trip только из `reportPageErrors()` — новая browser probe не + выдаёт требуемый красный вердикт, `verify-guard`/mutation gate падает; +3. fingerprint-only fallback заменён пустым trace — unit CLI-owned функции + показывает потерю прежних полей. + +Зелёный прогон без зафиксированного соответствующего red proof не закрывает AC. + +## Риски + +- **Backend test маскирует результат успешным relay mock.** Каждый submit + проверяет и WebSocket response, и число/байты transport calls. +- **TTL test становится flaky.** Время передаётся через monkeypatch + `time.monotonic`; ожидания и реальные таймеры запрещены. +- **Новая probe снова попадает в `finish()`.** Source/contract test фиксирует, + что её развязка вызывает `reportPageErrors()` напрямую. +- **Мутант ложится в одноимённый round-trip `finish()`.** Anchor включает имя + функции и уникальный соседний код; `--check` требует ровно одно совпадение. +- **Тест manifest helper расходится с CLI.** `main()` не дублирует ветку и + вызывает экспортированную функцию непосредственно. +- **Release mutation gate дорожает.** Добавляются только адресные guards; + backend/browser выполнение остаётся в существующих shard/time budgets. + +## Откат + +Откат одного implementation-коммита удаляет новые тесты/probe/mutants и +возвращает inline-сборку acceptance trace. Данные, API и пользовательская +конфигурация не мигрируются. Откат безопасен для runtime, но снова оставляет три +заявленных контракта без отрицательного доказательства. + +## Release-артефакты + +- changelog RU/EN не меняется: пользовательское поведение не изменено; +- golden, docs screenshots, performance profile и security artefact не нужны; +- документ код-ревью обязан перечислить red proof для AC1–AC7; +- issue остаётся открытым в `S8-merged` до пакетного закрытия при выпуске беты. + +## Принятые предположения + +- Текущая production-реализация трёх контрактов верна; задача чинит их + доказательства. Красный тест, выявивший реальную ошибку поведения, расширением + этой задачи автоматически не считается. +- Для backend mutation guard используется действующий + `scripts/backend-test-guard.mjs`, чтобы запускать один именованный HA-тест. +- Сохранение произвольных совместимых полей прежнего acceptance trace важнее + жёсткого перечисления только `declared/witnesses/floor`: fingerprint-only + refresh не является новой приёмкой и не должен переписывать её историю. +- Названия probe и mutant IDs технические и могут быть уточнены при реализации + без изменения AC. diff --git a/docs/specs/README.md b/docs/specs/README.md index ac8dd193..90bbc5bf 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -163,6 +163,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#373](https://github.com/Matysh/houseplan-card/issues/373) Плотное кадрирование static card по геометрии дома | [373-space-card-house-fit.md](373-space-card-house-fit.md) | | [#294](https://github.com/Matysh/houseplan-card/issues/294) Esc завершает текущую цепочку стен без удаления геометрии | [294-wall-esc-detach.md](294-wall-esc-detach.md) | | [#419](https://github.com/Matysh/houseplan-card/issues/419) Безопасная уборка Area-снапшота при пустом или усечённом HA-реестре | [419-area-snapshot-roster-guard.md](419-area-snapshot-roster-guard.md) | +| [#421](https://github.com/Matysh/houseplan-card/issues/421) Отрицательные доказательства для трёх защитных проверок | [421-negative-test-proofs.md](421-negative-test-proofs.md) | ## P3