Files
houseplan-card/docs/specs/421-negative-test-proofs.md
T
2026-09-02 20:04:10 +03:00

306 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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)
## Сценарий
Разработчик меняет lifecycle support-preview, browser error guard или CLI
приёмки скриншотов и запускает объявленную для этого контракта проверку. Сейчас
она может остаться зелёной после удаления защищающего механизма, поэтому
ревьюер получает ложное доказательство и регрессия может попасть в релиз.
После этой задачи тот же намеренный дефект воспроизводится адресным мутантом
либо контролируемым red proof. Гейт обязан покраснеть до merge; исправная версия
проходит тот же тест зелёной.
## Что человек увидит до и после
**До:** видимого дефекта в текущей версии нет, но три зелёные проверки не
гарантируют заявленное поведение. Будущая регрессия может незаметно вернуть
пригодность заменённого support-токена, пропустить browser exception или стереть
историю приёмки документационных кадров.
**После:** UI и штатные сценарии выглядят ровно так же. Пользователь получает
косвенный результат: эти три класса регрессий блокируются проверками до выпуска,
а не обнаруживаются после него.
## Цель
Сделать три существующих утверждения доказательными: тест или гейт обязан
краснеть, когда ломается ровно тот механизм, защитой которого он объявлен.
Пользовательское поведение не меняется; закрепляются уже действующие контракты.
## Проблема
### 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`;
- полный аудит всех тестов проекта за пределами трёх названных швов.
## UX
Новых экранов, сообщений, настроек и действий нет. Help & feedback сохраняет
действующие ответы и срок жизни preview; browser smokes и docs acceptance CLI
остаются инструментами разработки. Desktop, touch, kiosk, keyboard, focus и
ARIA не затронуты.
## Контракт
### 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 оформляется отдельно.
## Модель данных и миграция
Persisted config/layout, support-package schema, WebSocket payloads и runtime
records preview не меняются; миграция и compatibility-поля не нужны.
Тестируемая функция `docs-accept.mjs` перестраивает тот же manifest, который CLI
пишет сейчас. Формат `docs/images/screenshots.json` не расширяется: при реальной
замене пикселей создаётся уже существующий acceptance trace, а при
fingerprint-only refresh переносятся его прежние поля и добавляется уже
действующий `lastWriteWasFingerprintOnly`. Старые manifests без `acceptance`
остаются допустимыми.
## 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.