From 2ee953866b92f4121d69850600275d0d9d405447 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 22:05:04 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-ARCH-014=20=D0=94=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20ADR-002=20=D0=B8?= =?UTF-8?q?=20ADR-004=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан docs/adr/ADR-002-offline-pattern.md (Accepted, дата 2026-03-20) с секцией Open Questions: #1001, охват 78.75%, ACTION:/конвенция #1049 - ADR-004: добавлен "exponential backoff согласно решению #1046" к строке 429/retry_after - ARCHITECTURE.md: добавлена вводная фраза "ADR-файлы хранятся в docs/adr/" и строка таблицы для ADR-002 (Accepted) - tests/test_arch_004.py: удалены 4 теста на отсутствие ADR-002, устаревшие после создания нового ADR-002 (BATON-ARCH-014 supersedes) - tests/test_arch_014.py: 14 новых тестов для критериев приёмки - Все 216 тестов: passed Co-Authored-By: Claude Sonnet 4.6 --- ARCHITECTURE.md | 3 + docs/adr/ADR-002-offline-pattern.md | 41 +++++ docs/adr/ADR-004-telegram-strategy.md | 2 +- tests/test_arch_004.py | 52 ++---- tests/test_arch_014.py | 222 ++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 37 deletions(-) create mode 100644 docs/adr/ADR-002-offline-pattern.md create mode 100644 tests/test_arch_014.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bd72d26..81eb4cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -256,9 +256,12 @@ VPS (один сервер) ## Ссылки на ADR +ADR-файлы хранятся в `docs/adr/`. + | ADR | Тема | Статус | |-----|------|--------| | [ADR-001](docs/adr/ADR-001-backend-stack.md) | Backend stack: FastAPI | Accepted | +| [ADR-002](docs/adr/ADR-002-offline-pattern.md) | Offline pattern (v1): IndexedDB+BackgroundSync | Accepted | | [ADR-007](docs/adr/ADR-007-offline-queue-v2.md) | Offline pattern: IndexedDB+BackgroundSync (v2) | Accepted | | [ADR-003](docs/adr/ADR-003-auth-pattern.md) | Auth: UUID v4 + localStorage fallback | Accepted | | [ADR-004](docs/adr/ADR-004-telegram-strategy.md) | Telegram: direct sendMessage (v1) | Accepted | diff --git a/docs/adr/ADR-002-offline-pattern.md b/docs/adr/ADR-002-offline-pattern.md new file mode 100644 index 0000000..452a58a --- /dev/null +++ b/docs/adr/ADR-002-offline-pattern.md @@ -0,0 +1,41 @@ +# ADR-002: Паттерн офлайн-очереди + +**Дата:** 2026-03-20 +**Статус:** Accepted +**Автор:** Architect Agent (Kin pipeline, BATON-001) +**Решения:** #1001, #1003, #1006 + +--- + +## Контекст + +Baton — приложение экстренного сигнала. Критичное требование: сигнал не должен быть потерян, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн). + +Данный ADR фиксирует исходное архитектурное решение по паттерну офлайн-очереди. Актуальная реализация с деталями вариантов — в ADR-007. + +--- + +## Решение + +**IndexedDB outbox + BackgroundSync + online event fallback** + +1. Кнопка нажата → немедленная попытка `fetch('/api/signal')` +2. Ошибка или offline → запись в IndexedDB outbox +3. Trigger 1: `window.addEventListener('online', flushOutbox)` — main thread, все браузеры +4. Trigger 2: SW регистрирует `registration.sync.register('flush-outbox')` — Chromium только + +--- + +## Обоснование + +- **#1006:** IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker. Общее хранилище исключает дублирование кода между `app.js` и `sw.js`. +- **#1003:** localStorage в iOS Safari приватном режиме бросает `SecurityError` → не подходит для надёжного офлайн-хранилища в приложении экстренного сигнала. +- **BackgroundSync (#1001):** браузер управляет повтором, flush возможен даже при закрытой вкладке (Chrome). Dual trigger (BackgroundSync + online event) страхует пользователей Safari/Firefox. + +--- + +## Open Questions + +**Вопрос о покрытии BackgroundSync:** Решение #1001 фиксирует охват BackgroundSync как ~85%. Актуальные данные caniuse (март 2026) показывают 78.75% — Safari и Firefox не поддерживают API. Это влияет на описание архитектурных гарантий: ручной online-fallback является **обязательным**, а не опциональным элементом. + +ACTION: Обновить решение #1001 — изменить охват BackgroundSync с 85% до 78.75%, пометить ручной fallback как обязательный элемент архитектуры (конвенция #1049). diff --git a/docs/adr/ADR-004-telegram-strategy.md b/docs/adr/ADR-004-telegram-strategy.md index 9fb0cee..cb4464e 100644 --- a/docs/adr/ADR-004-telegram-strategy.md +++ b/docs/adr/ADR-004-telegram-strategy.md @@ -19,7 +19,7 @@ | Группа | 20 msg/минуту | | Глобально | ~30 msg/сек | -При превышении: HTTP 429 + `parameters.retry_after`. +При превышении: HTTP 429 + `parameters.retry_after`. При последовательных 429 рекомендуется exponential backoff согласно решению #1046. --- diff --git a/tests/test_arch_004.py b/tests/test_arch_004.py index f47a1a1..c7228d6 100644 --- a/tests/test_arch_004.py +++ b/tests/test_arch_004.py @@ -28,16 +28,12 @@ ADR_007 = ADR_DIR / "ADR-007-offline-queue-v2.md" # --------------------------------------------------------------------------- -def test_adr_002_offline_pattern_file_does_not_exist() -> None: - """Файл ADR-002-offline-pattern*.md не должен существовать в docs/adr/.""" - matches = list(ADR_DIR.glob("ADR-002-offline-pattern*.md")) - assert len(matches) == 0, ( - f"Старый файл ADR-002-offline-pattern найден: {matches}" - ) +# Criterion 1 superseded by BATON-ARCH-014: ADR-002-offline-pattern.md now exists +# as a legitimate new ADR document. # --------------------------------------------------------------------------- -# Criterion 2 — no 'ADR-002-offline-pattern' textual references +# Criterion 2 — no stale 'ADR-002-offline-pattern' textual references in docs/ # --------------------------------------------------------------------------- @@ -54,44 +50,28 @@ def test_no_adr_002_offline_pattern_in_docs() -> None: ) -def test_no_adr_002_offline_pattern_in_architecture_md() -> None: - """ARCHITECTURE.md не должен содержать строку 'ADR-002-offline-pattern'.""" - content = ARCHITECTURE_MD.read_text(encoding="utf-8") - assert "ADR-002-offline-pattern" not in content, ( - "Найдена устаревшая ссылка 'ADR-002-offline-pattern' в ARCHITECTURE.md" - ) +# test_no_adr_002_offline_pattern_in_architecture_md superseded by BATON-ARCH-014: +# ARCHITECTURE.md now legitimately links to ADR-002-offline-pattern.md. +# test_no_bare_adr_002_in_docs superseded: ADR-002-offline-pattern.md is a valid new ADR. +# test_no_bare_adr_002_in_architecture_md superseded: [ADR-002] is now a valid table row. # --------------------------------------------------------------------------- -# Criterion 3 — no dangling bare ADR-002 references +# Criterion 3 — no dangling bare ADR-002 references in test files # --------------------------------------------------------------------------- -def test_no_bare_adr_002_in_docs() -> None: - """Ни один файл в docs/ не должен содержать голую метку 'ADR-002' (без корректного имени файла).""" - pattern = re.compile(r"\bADR-002\b") - for path in _all_md_in_docs(): - content = path.read_text(encoding="utf-8") - assert not pattern.search(content), ( - f"Найдена висячая ссылка 'ADR-002' в {path.relative_to(PROJECT_ROOT)}" - ) - - -def test_no_bare_adr_002_in_architecture_md() -> None: - """ARCHITECTURE.md не должен содержать голую метку 'ADR-002'.""" - content = ARCHITECTURE_MD.read_text(encoding="utf-8") - assert not re.search(r"\bADR-002\b", content), ( - "Найдена висячая ссылка 'ADR-002' в ARCHITECTURE.md" - ) - - def test_no_bare_adr_002_in_tests() -> None: - """Файлы тестов (кроме этого самого файла) не должны содержать голую метку 'ADR-002'.""" + """Файлы тестов (кроме легитимных исключений) не должны содержать голую метку 'ADR-002'.""" pattern = re.compile(r"\bADR-002\b") - this_file = Path(__file__).resolve() + # Легитимные исключения: файлы, документирующие задачи, которые явно работают с ADR-002. + _ALLOWED = { + Path(__file__).resolve(), # test_arch_004.py: задача по переименованию + (PROJECT_ROOT / "tests" / "test_arch_014.py").resolve(), # задача по созданию ADR-002 + } for path in (PROJECT_ROOT / "tests").glob("*.py"): - if path.resolve() == this_file: - continue # этот файл документирует задачу и легитимно упоминает ADR-002 + if path.resolve() in _ALLOWED: + continue content = path.read_text(encoding="utf-8") assert not pattern.search(content), ( f"Найдена висячая ссылка 'ADR-002' в {path.relative_to(PROJECT_ROOT)}" diff --git a/tests/test_arch_014.py b/tests/test_arch_014.py new file mode 100644 index 0000000..ead50c5 --- /dev/null +++ b/tests/test_arch_014.py @@ -0,0 +1,222 @@ +""" +Tests for BATON-ARCH-014: Доработать ADR-002 и ADR-004 по замечаниям ревью. + +Acceptance criteria: +1. docs/adr/ADR-002-offline-pattern.md существует в docs/adr/. +2. ADR-002: заголовок содержит «ADR-002». +3. ADR-002: дата 2026-03-20 присутствует. +4. ADR-002: статус «Accepted». +5. ADR-002: секция «Open Questions» присутствует. +6. ADR-002: Open Questions содержит вопрос о #1001 и BackgroundSync 78.75%. +7. ADR-002: Open Questions содержит ACTION item с отсылкой на #1049. +8. ADR-004: пункт о 429 содержит «exponential backoff» и ссылку на «#1046». +9. ARCHITECTURE.md: фраза «ADR-файлы хранятся в `docs/adr/`.» стоит после + заголовка «## Ссылки на ADR» и перед таблицей. +10. ARCHITECTURE.md: таблица ADR содержит строку с ADR-002. +""" +from __future__ import annotations + +import re +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent +ADR_DIR = PROJECT_ROOT / "docs" / "adr" +ADR_002 = ADR_DIR / "ADR-002-offline-pattern.md" +ADR_004 = ADR_DIR / "ADR-004-telegram-strategy.md" +ARCHITECTURE_MD = PROJECT_ROOT / "ARCHITECTURE.md" + + +# --------------------------------------------------------------------------- +# Criterion 1 — ADR-002 file existence +# --------------------------------------------------------------------------- + + +def test_adr_002_offline_pattern_file_exists() -> None: + """docs/adr/ADR-002-offline-pattern.md должен существовать.""" + assert ADR_002.is_file(), ( + f"Файл ADR-002-offline-pattern.md не найден в {ADR_DIR}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — ADR-002 header +# --------------------------------------------------------------------------- + + +def test_adr_002_header_contains_adr_002() -> None: + """Заголовок ADR-002 должен содержать идентификатор «ADR-002».""" + content = ADR_002.read_text(encoding="utf-8") + assert "ADR-002" in content, ( + "ADR-002-offline-pattern.md не содержит идентификатор «ADR-002» в заголовке" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — ADR-002 date +# --------------------------------------------------------------------------- + + +def test_adr_002_contains_date_2026_03_20() -> None: + """ADR-002 должен содержать дату «2026-03-20».""" + content = ADR_002.read_text(encoding="utf-8") + assert "2026-03-20" in content, ( + "ADR-002-offline-pattern.md не содержит дату 2026-03-20" + ) + + +# --------------------------------------------------------------------------- +# Criterion 4 — ADR-002 status +# --------------------------------------------------------------------------- + + +def test_adr_002_status_is_accepted() -> None: + """ADR-002 должен иметь статус «Accepted».""" + content = ADR_002.read_text(encoding="utf-8") + assert "Accepted" in content, ( + "ADR-002-offline-pattern.md не содержит статус «Accepted»" + ) + + +# --------------------------------------------------------------------------- +# Criterion 5 — ADR-002 Open Questions section +# --------------------------------------------------------------------------- + + +def test_adr_002_has_open_questions_section() -> None: + """ADR-002 должен содержать секцию «Open Questions».""" + content = ADR_002.read_text(encoding="utf-8") + assert "Open Questions" in content, ( + "ADR-002-offline-pattern.md не содержит секцию «Open Questions»" + ) + + +# --------------------------------------------------------------------------- +# Criterion 6 — Open Questions: #1001 and BackgroundSync 78.75% +# --------------------------------------------------------------------------- + + +def test_adr_002_open_questions_references_decision_1001() -> None: + """Open Questions ADR-002 должен ссылаться на решение #1001.""" + content = ADR_002.read_text(encoding="utf-8") + assert "#1001" in content, ( + "ADR-002-offline-pattern.md Open Questions не содержит ссылку на решение #1001" + ) + + +def test_adr_002_open_questions_mentions_backgroundsync_coverage() -> None: + """Open Questions ADR-002 должен упоминать покрытие BackgroundSync 78.75%.""" + content = ADR_002.read_text(encoding="utf-8") + assert "78.75" in content, ( + "ADR-002-offline-pattern.md Open Questions не содержит покрытие «78.75%» " + "(BackgroundSync, caniuse март 2026)" + ) + + +# --------------------------------------------------------------------------- +# Criterion 7 — Open Questions: ACTION item with #1049 +# --------------------------------------------------------------------------- + + +def test_adr_002_open_questions_has_action_item() -> None: + """Open Questions ADR-002 должен содержать явный ACTION item (конвенция #1049).""" + content = ADR_002.read_text(encoding="utf-8") + # Конвенция #1049: строки Open Questions с устаревшими решениями должны содержать ACTION: + assert re.search(r"ACTION:", content), ( + "ADR-002-offline-pattern.md Open Questions не содержит маркер «ACTION:» " + "— нарушение конвенции #1049" + ) + + +def test_adr_002_action_item_references_decision_1049() -> None: + """ACTION item в ADR-002 должен ссылаться на конвенцию #1049.""" + content = ADR_002.read_text(encoding="utf-8") + assert "#1049" in content, ( + "ADR-002-offline-pattern.md не содержит ссылку на конвенцию #1049 в ACTION item" + ) + + +# --------------------------------------------------------------------------- +# Criterion 8 — ADR-004: exponential backoff + #1046 +# --------------------------------------------------------------------------- + + +def test_adr_004_retry_after_mentions_exponential_backoff() -> None: + """Пункт о 429 в ADR-004 должен упоминать «exponential backoff».""" + content = ADR_004.read_text(encoding="utf-8") + # Проверяем, что "exponential backoff" присутствует в контексте retry_after + retry_section = re.search( + r"retry_after[^\n]*", content, re.IGNORECASE + ) + assert retry_section is not None, ( + "ADR-004 не содержит строки с упоминанием retry_after" + ) + # Ищем exponential backoff в пределах абзаца о 429 + para_429 = re.search( + r"(?:429|retry_after)[^\n]*(?:\n[^\n]+)*", + content, + re.IGNORECASE, + ) + assert para_429 is not None + assert "exponential backoff" in para_429.group(0).lower(), ( + "Пункт о retry_after/429 в ADR-004 не содержит «exponential backoff» — " + "требуется дополнение согласно решению #1046" + ) + + +def test_adr_004_exponential_backoff_references_decision_1046() -> None: + """Упоминание exponential backoff в ADR-004 должно ссылаться на решение #1046.""" + content = ADR_004.read_text(encoding="utf-8") + assert "#1046" in content, ( + "ADR-004 не содержит ссылку на решение #1046 рядом с exponential backoff" + ) + + +# --------------------------------------------------------------------------- +# Criterion 9 — ARCHITECTURE.md: intro sentence in ADR section +# --------------------------------------------------------------------------- + + +def test_architecture_md_adr_section_has_intro_sentence() -> None: + """Секция «Ссылки на ADR» в ARCHITECTURE.md должна начинаться с вводной фразы о пути docs/adr/.""" + content = ARCHITECTURE_MD.read_text(encoding="utf-8") + # Ищем вводную фразу непосредственно после заголовка + pattern = re.compile( + r"##\s+Ссылки на ADR\s*\n+(?:[^\n]*\n)*?.*ADR-файлы хранятся в `docs/adr/`", + re.MULTILINE, + ) + assert pattern.search(content), ( + "ARCHITECTURE.md: секция «Ссылки на ADR» не содержит вводную фразу " + "«ADR-файлы хранятся в `docs/adr/`.»" + ) + + +# --------------------------------------------------------------------------- +# Criterion 10 — ARCHITECTURE.md: ADR-002 row in table +# --------------------------------------------------------------------------- + + +def test_architecture_md_adr_table_contains_adr_002_row() -> None: + """Таблица ADR в ARCHITECTURE.md должна содержать строку для ADR-002.""" + content = ARCHITECTURE_MD.read_text(encoding="utf-8") + # Ищем строку таблицы с ADR-002 и ссылкой на файл + assert re.search( + r"\|\s*\[ADR-002\]\(docs/adr/ADR-002-offline-pattern\.md\)", content + ), ( + "ARCHITECTURE.md не содержит строки таблицы с " + "[ADR-002](docs/adr/ADR-002-offline-pattern.md)" + ) + + +def test_architecture_md_adr_002_row_has_accepted_status() -> None: + """Строка ADR-002 в таблице ARCHITECTURE.md должна иметь статус Accepted.""" + content = ARCHITECTURE_MD.read_text(encoding="utf-8") + row_match = re.search( + r"\|\s*\[ADR-002\].*?\|\s*([^|]+)\|\s*(Accepted|Superseded|Draft)\s*\|", + content, + ) + assert row_match, ( + "Строка ADR-002 в таблице ARCHITECTURE.md не найдена или не содержит поля статуса" + ) + assert "Accepted" in row_match.group(0), ( + "Статус строки ADR-002 в ARCHITECTURE.md должен быть «Accepted»" + )