Расширяю слайдо-источник презентации docs/overview/presentation.md тремя слайдами в каноне ORCH-011 (16 → 19, сквозная нумерация сохранена): - Слайд «Запуск и ведение задачи через Plane» (вход «To Analyse», статусы = индикация, наблюдение: доска + Telegram-карточка + комментарии). - Слайд «Что решает человек: гейты, авто-режим, отмена» (Approved / Confirm Deploy; autoApprove/autoDeploy/Bug — без пропуска тех. проверок; STOP). - Слайд «Lite-установка скриптами» (два контейнера платформы; только конфиг; gen_secrets.py/onboard_project.py + docker compose up -d; runbook LITE_SETUP.md; одношаговый bootstrap — это смежный Bundled, не Lite). Факты сверены с golden sources (LITE_SETUP.md, tech-pipeline.md, tech-integrations.md, CLAUDE.md). Анти-дрейф — новая функция test_presentation_covers_lite_and_plane_usage_bits в tests/test_system_docs.py (существующие проверки без послаблений). CHANGELOG обновлён. Docs+tests only: src/**/STAGE_TRANSITIONS/QG_CHECKS/check_*/схема БД — байт-в-байт; python-pptx не в прод-образе; .pptx в git не коммитится. Ручная сборка .pptx (TC-07) проверена в dev-venv: «Собрано слайдов: 19», exit 0. Refs: ORCH-105 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
478 lines
25 KiB
Python
478 lines
25 KiB
Python
"""ORCH-011 (FR-7 / AC-1…AC-12): анти-дрейф контур витрины системы `docs/overview/`.
|
||
|
||
Структурные проверки витрины (ADR-001 D6, образец — `tests/test_lite_setup_doc.py` /
|
||
`test_bundled_setup_doc.py`): 10 файлов D1 существуют и непусты (TC-01); индекс несёт
|
||
маршруты трёх аудиторий и норматив сопровождения, из него достижимы все части (TC-02);
|
||
бизнес-часть несёт 5 обязательных смысловых разделов и ≥5 сценариев (AC-2);
|
||
карта стадий и гейтов derive-сверяется импортом `src.stages.STAGE_TRANSITIONS` и
|
||
`src.qg.checks.QG_CHECKS` — не статичным списком (TC-03/TC-04, паттерн ORCH-091);
|
||
полнота агентов derive из glob `.openclaw/agents/*.md`, таблица эффортов — из
|
||
class-default'ов `src.config.Settings` (TC-05, ORCH-41/81); все относительные ссылки
|
||
резолвятся, обязательные golden-source ссылки AC-6 присутствуют (TC-06); гигиена —
|
||
полнотекстовый FORBIDDEN-скан (импорт из `tests/test_no_host_hardcodes.py`, не копия) +
|
||
секрет-эвристика + запрет вне-репозиторных путей (TC-07, AC-12); слайдо-источник
|
||
валидируется каноническим парсером `parse_slides` из `scripts/build_presentation.py`
|
||
(TC-08, один парсер — один источник истины о формате, прецедент
|
||
`test_bootstrap_script.py`); NFR-2 машинно: `pptx` отсутствует в `requirements*` /
|
||
`Dockerfile` (TC-09); указатели README/CLAUDE/CHANGELOG обновлены (TC-10).
|
||
Детерминировано: без сети/LLM/subprocess. Новый QG НЕ регистрируется (ТЗ §6) —
|
||
модуль исполняется существующими гейтами (`check_ci_green` / `check_tests_passed`).
|
||
"""
|
||
|
||
import ast
|
||
import importlib.util
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from src.config import Settings
|
||
from src.qg.checks import QG_CHECKS
|
||
from src.stages import STAGE_TRANSITIONS
|
||
|
||
# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7).
|
||
from tests.test_no_host_hardcodes import FORBIDDEN
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
OVERVIEW = REPO_ROOT / "docs" / "overview"
|
||
AGENTS_DIR = REPO_ROOT / ".openclaw" / "agents"
|
||
BUILD_SCRIPT = REPO_ROOT / "scripts" / "build_presentation.py"
|
||
|
||
# Нормативный состав витрины (ADR-001 D1: плоский каталог, 10 файлов).
|
||
SHOWCASE_FILES: tuple[str, ...] = (
|
||
"README.md",
|
||
"business.md",
|
||
"tech-architecture.md",
|
||
"tech-pipeline.md",
|
||
"tech-agents.md",
|
||
"tech-data-model.md",
|
||
"tech-integrations.md",
|
||
"tech-quality-security.md",
|
||
"tech-observability.md",
|
||
"presentation.md",
|
||
)
|
||
|
||
# Под-гейты ребра deploy-staging→deploy: фактические имена реестра QG_CHECKS в
|
||
# нормативном порядке security → merge → coverage → image-freshness (adr-0029).
|
||
SUBGATES_IN_ORDER: tuple[str, ...] = (
|
||
"check_security_gate",
|
||
"check_branch_mergeable",
|
||
"check_coverage_gate",
|
||
"check_staging_image_fresh",
|
||
)
|
||
|
||
# Обязательные golden-source ссылки витрины (AC-6), repo-relative POSIX.
|
||
MANDATORY_LINK_TARGETS: tuple[str, ...] = (
|
||
"docs/architecture/README.md",
|
||
"docs/architecture/internals.md",
|
||
"docs/_standards/PIPELINE_DOCS.md",
|
||
"docs/_standards/HANDOFF_PROTOCOL.md",
|
||
"docs/architecture/adr",
|
||
"docs/deployment/LITE_SETUP.md",
|
||
"docs/deployment/BUNDLED_SETUP.md",
|
||
"docs/PRODUCT_VISION.md",
|
||
"CLAUDE.md",
|
||
)
|
||
|
||
# Маркдаун-ссылка: [текст](цель) — цель без пробелов.
|
||
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||
|
||
# Секрет-эвристика (паттерн ORCH-102 D8): hex-run >= 32 / чистый alnum-run >= 40.
|
||
_SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
|
||
_SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# helpers
|
||
# ---------------------------------------------------------------------------
|
||
def _read(name: str) -> str:
|
||
"""Текст файла витрины; падает с внятным сообщением, если файла нет."""
|
||
path = OVERVIEW / name
|
||
assert path.is_file(), f"docs/overview/{name} отсутствует (D1 / AC-1)"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _stage_token_re(stage: str) -> re.Pattern:
|
||
"""Регэксп стадии с границами: `deploy` не матчится внутри `deploy-staging`,
|
||
`review` — внутри `reviewer`/`12-review.md` (дефис и \\w исключены с обеих сторон)."""
|
||
return re.compile(rf"(?<![\w-]){re.escape(stage)}(?![\w-])")
|
||
|
||
|
||
def _first_stage_pos(text: str, stage: str) -> int:
|
||
"""Позиция первого вхождения стадии (с границами) или -1."""
|
||
m = _stage_token_re(stage).search(text)
|
||
return m.start() if m else -1
|
||
|
||
|
||
def _main_chain() -> list[str]:
|
||
"""Основная цепочка стадий, derive проходом по `next` от created (не статика)."""
|
||
chain: list[str] = []
|
||
stage = "created"
|
||
while stage is not None:
|
||
chain.append(stage)
|
||
stage = STAGE_TRANSITIONS[stage]["next"]
|
||
return chain
|
||
|
||
|
||
def _rel_link_targets(name: str) -> list[str]:
|
||
"""Относительные цели md-ссылок файла (внешние URL и якоря — мимо)."""
|
||
targets: list[str] = []
|
||
for target in _LINK_RE.findall(_read(name)):
|
||
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||
continue
|
||
targets.append(target.split("#", 1)[0])
|
||
return [t for t in targets if t]
|
||
|
||
|
||
def _load_build_module():
|
||
"""Импорт scripts/build_presentation.py по файлу (прецедент test_bootstrap_script):
|
||
top-level скрипта обязан быть stdlib-only, иначе сам импорт здесь упадёт."""
|
||
spec = importlib.util.spec_from_file_location("build_presentation", BUILD_SCRIPT)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
# Регистрация в sys.modules ДО exec: dataclass резолвит строковые аннотации
|
||
# (`from __future__ import annotations`) через sys.modules[cls.__module__].
|
||
sys.modules[spec.name] = mod
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-01: все 10 файлов витрины существуют и непусты (AC-1 / D1).
|
||
# ---------------------------------------------------------------------------
|
||
def test_all_showcase_files_exist_and_nonempty():
|
||
for name in SHOWCASE_FILES:
|
||
text = _read(name).strip()
|
||
assert len(text) > 300, f"docs/overview/{name} подозрительно пуст (D1)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-02: индекс — маршруты 3 аудиторий, норматив, достижимость всех частей (AC-1/8/9).
|
||
# ---------------------------------------------------------------------------
|
||
def test_index_carries_three_audience_routes():
|
||
text = _read("README.md")
|
||
for route in ("Я заказчик", "Я менеджер", "Я разработчик"):
|
||
assert route in text, f"маршрут {route!r} отсутствует в индексе (FR-5 / AC-8)"
|
||
|
||
|
||
def test_index_carries_maintenance_normative():
|
||
text = _read("README.md")
|
||
assert "в том же PR" in text, (
|
||
"норматив сопровождения «изменил функциональность → обнови витрину "
|
||
"в том же PR» отсутствует в индексе (FR-6 / AC-9)"
|
||
)
|
||
|
||
|
||
def test_index_links_reach_every_showcase_part():
|
||
targets = {t.removeprefix("./") for t in _rel_link_targets("README.md")}
|
||
for name in SHOWCASE_FILES[1:]:
|
||
assert name in targets, (
|
||
f"{name} недостижим из индекса по относительной ссылке (AC-1)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-03: бизнес-часть — 5 обязательных разделов + >=5 сценариев (AC-2).
|
||
# ---------------------------------------------------------------------------
|
||
def test_business_part_has_five_mandatory_sections():
|
||
text = _read("business.md")
|
||
for marker in ("## Проблема", "## Решение", "Что умеет", "## Ценность", "## Сценарии"):
|
||
assert marker in text, f"бизнес-раздел {marker!r} отсутствует (FR-2 / AC-2)"
|
||
|
||
|
||
def test_business_part_has_at_least_five_scenarios():
|
||
text = _read("business.md")
|
||
count = text.count("### Сценарий")
|
||
assert count >= 5, f"в business.md {count} сценариев, требуется >= 5 (FR-2 / AC-2)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-04: 7 тех-блоков присутствуют (через TC-01) + схема потока в блоке 1 (AC-3).
|
||
# ---------------------------------------------------------------------------
|
||
def test_architecture_block_carries_flow_diagram():
|
||
text = _read("tech-architecture.md")
|
||
fenced = re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
|
||
assert fenced, "tech-architecture.md не несёт ни одного fenced-блока со схемой (AC-3)"
|
||
flow = [b for b in fenced if ("вебхук" in b.lower() or "webhook" in b.lower()) and "→" in b]
|
||
assert flow, (
|
||
"схема потока «вебхук → очередь → агент → гейт → переход» отсутствует "
|
||
"в tech-architecture.md (FR-3.1 / AC-3)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-05 (план TC-05): карта стадий = код, derive из STAGE_TRANSITIONS (AC-4).
|
||
# ---------------------------------------------------------------------------
|
||
def test_every_stage_from_code_is_mentioned_in_pipeline_doc():
|
||
text = _read("tech-pipeline.md")
|
||
missing = [s for s in STAGE_TRANSITIONS if _first_stage_pos(text, s) == -1]
|
||
assert not missing, (
|
||
f"стадии из src.stages.STAGE_TRANSITIONS отсутствуют в tech-pipeline.md "
|
||
f"(AC-4): {missing}"
|
||
)
|
||
|
||
|
||
def test_main_chain_order_in_pipeline_doc_matches_code():
|
||
text = _read("tech-pipeline.md")
|
||
chain = _main_chain()
|
||
positions = [_first_stage_pos(text, s) for s in chain]
|
||
assert -1 not in positions, "стадия основной цепочки не найдена в tech-pipeline.md"
|
||
assert positions == sorted(positions), (
|
||
f"порядок первых вхождений стадий {chain} в tech-pipeline.md противоречит "
|
||
f"коду (AC-4): позиции {positions}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-06 (план TC-06): гейты = код; под-гейты в нормативном порядке (AC-4).
|
||
# ---------------------------------------------------------------------------
|
||
def test_every_exit_gate_from_code_is_named_in_pipeline_doc():
|
||
text = _read("tech-pipeline.md")
|
||
exit_gates = {t["qg"] for t in STAGE_TRANSITIONS.values() if t["qg"]}
|
||
missing = sorted(g for g in exit_gates if g not in text)
|
||
assert not missing, f"exit-гейты рёбер не названы в tech-pipeline.md (AC-4): {missing}"
|
||
|
||
|
||
def test_no_invented_gate_names_anywhere_in_showcase():
|
||
"""Каждое упомянутое имя check_* существует в реестре QG_CHECKS (анти-выдумка)."""
|
||
for name in SHOWCASE_FILES:
|
||
mentioned = set(re.findall(r"check_[a-z_]+[a-z]", _read(name)))
|
||
unknown = sorted(mentioned - set(QG_CHECKS))
|
||
assert not unknown, (
|
||
f"docs/overview/{name} упоминает несуществующие гейты (AC-4): {unknown}"
|
||
)
|
||
|
||
|
||
def test_subgates_in_normative_order_and_marked_as_insets():
|
||
text = _read("tech-pipeline.md")
|
||
for gate in SUBGATES_IN_ORDER:
|
||
assert gate in QG_CHECKS, f"под-гейт {gate} исчез из реестра QG_CHECKS"
|
||
positions = [text.find(g) for g in SUBGATES_IN_ORDER]
|
||
assert -1 not in positions, (
|
||
f"под-гейт ребра deploy-staging→deploy не назван в tech-pipeline.md: "
|
||
f"{[g for g, p in zip(SUBGATES_IN_ORDER, positions) if p == -1]}"
|
||
)
|
||
assert positions == sorted(positions), (
|
||
"под-гейты идут не в нормативном порядке security → merge → coverage → "
|
||
"image-freshness (adr-0029 / AC-4)"
|
||
)
|
||
assert "не стадии" in text, (
|
||
"tech-pipeline.md обязан явно помечать под-гейты как врезки в переход, "
|
||
"«не стадии» (AC-4)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-07 (план TC-07): полнота агентов derive из glob; эффорты = config (AC-5).
|
||
# ---------------------------------------------------------------------------
|
||
def test_every_agent_prompt_stem_is_covered():
|
||
stems = sorted(p.stem for p in AGENTS_DIR.glob("*.md"))
|
||
assert stems, ".openclaw/agents/*.md не найдены — glob сломан"
|
||
text = _read("tech-agents.md")
|
||
missing = [s for s in stems if s not in text]
|
||
assert not missing, f"роли из .openclaw/agents/ не описаны в tech-agents.md: {missing}"
|
||
|
||
|
||
def test_effort_table_matches_config_class_defaults():
|
||
"""Таблица модель/эффорт сходится с class-default'ами Settings (ORCH-41/81)."""
|
||
text = _read("tech-agents.md")
|
||
table_rows = [ln for ln in text.splitlines() if ln.strip().startswith("|")]
|
||
assert table_rows, "tech-agents.md не несёт таблицы модель/эффорт (AC-5)"
|
||
model_default = Settings.model_fields["agent_model_default"].default
|
||
assert model_default in text, (
|
||
f"дефолтная модель {model_default!r} (config) не упомянута в tech-agents.md"
|
||
)
|
||
effort_default = Settings.model_fields["agent_effort_default"].default
|
||
for stem in sorted(p.stem for p in AGENTS_DIR.glob("*.md")):
|
||
fld = Settings.model_fields.get(f"agent_effort_{stem}")
|
||
effort = (fld.default if fld else "") or effort_default
|
||
role_rows = [ln for ln in table_rows if stem in ln]
|
||
assert role_rows, f"строка таблицы для роли {stem!r} отсутствует (AC-5)"
|
||
assert any(f"`{effort}`" in ln for ln in role_rows), (
|
||
f"эффорт роли {stem!r} в таблице разъехался с config "
|
||
f"(ожидается `{effort}`, ORCH-81)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-08 (план TC-08/TC-09): валидность ссылок + обязательные golden sources (AC-6).
|
||
# ---------------------------------------------------------------------------
|
||
def test_all_relative_links_resolve_to_existing_files():
|
||
broken: list[str] = []
|
||
for name in SHOWCASE_FILES:
|
||
for target in _rel_link_targets(name):
|
||
if not (OVERVIEW / target).resolve().exists():
|
||
broken.append(f"{name}: {target}")
|
||
assert not broken, "битые относительные ссылки витрины (AC-6):\n" + "\n".join(broken)
|
||
|
||
|
||
def test_mandatory_golden_source_links_present():
|
||
resolved: set[str] = set()
|
||
for name in SHOWCASE_FILES:
|
||
for target in _rel_link_targets(name):
|
||
path = (OVERVIEW / target).resolve()
|
||
if path.exists():
|
||
resolved.add(path.relative_to(REPO_ROOT).as_posix())
|
||
missing = [t for t in MANDATORY_LINK_TARGETS if t not in resolved]
|
||
assert not missing, f"обязательные ссылки на golden sources отсутствуют (AC-6): {missing}"
|
||
|
||
|
||
def test_no_out_of_repo_references():
|
||
"""AC-12: витрина самодостаточна — никаких ссылок на вне-репозиторные пути."""
|
||
for name in SHOWCASE_FILES:
|
||
text = _read(name)
|
||
for needle in ("tasks/", "memory/"):
|
||
assert needle not in text, (
|
||
f"docs/overview/{name} ссылается на вне-репозиторный путь "
|
||
f"{needle!r} (AC-12)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-09 (план TC-13): гигиена — FORBIDDEN-скан полнотекстом + секрет-эвристика.
|
||
# ---------------------------------------------------------------------------
|
||
def test_showcase_carries_no_forbidden_host_literals():
|
||
"""Полнотекстовый скан (шире fenced-скана ORCH-102 — обоснование ADR D6)."""
|
||
offenders = [
|
||
f"{name}: {literal!r}"
|
||
for name in SHOWCASE_FILES
|
||
for literal in FORBIDDEN
|
||
if literal in _read(name)
|
||
]
|
||
assert not offenders, (
|
||
"боевые хост-литералы в витрине (NFR-3 / AC-11):\n" + "\n".join(offenders)
|
||
)
|
||
|
||
|
||
def test_showcase_carries_no_secret_like_values():
|
||
offenders = []
|
||
for name in SHOWCASE_FILES:
|
||
for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
|
||
m = rx.search(_read(name))
|
||
if m is not None:
|
||
offenders.append(f"{name}: {m.group(0)[:16]}…")
|
||
assert not offenders, (
|
||
"секретоподобные значения в витрине (NFR-3):\n" + "\n".join(offenders)
|
||
)
|
||
|
||
|
||
def test_secret_heuristic_is_not_evergreen():
|
||
"""Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит."""
|
||
assert _SECRET_HEX_RE.search("token=" + "0f" * 20) is not None
|
||
assert _SECRET_ALNUM_RE.search("bot" + "Q7" * 25) is not None
|
||
assert _SECRET_HEX_RE.search("обычный текст витрины 8500/8501") is None
|
||
assert _SECRET_ALNUM_RE.search("`check_staging_image_fresh` и `STAGE_TRANSITIONS`") is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-10 (план TC-10): слайдо-источник через канонический парсер (AC-7 / D4).
|
||
# ---------------------------------------------------------------------------
|
||
def test_build_script_toplevel_imports_are_stdlib_only():
|
||
"""Ленивый импорт pptx (D4): top-level скрипта не тянет python-pptx."""
|
||
tree = ast.parse(BUILD_SCRIPT.read_text(encoding="utf-8"))
|
||
top_imports: set[str] = set()
|
||
for node in tree.body:
|
||
if isinstance(node, ast.Import):
|
||
top_imports.update(alias.name.split(".")[0] for alias in node.names)
|
||
elif isinstance(node, ast.ImportFrom):
|
||
top_imports.add((node.module or "").split(".")[0])
|
||
assert "pptx" not in top_imports, (
|
||
"scripts/build_presentation.py импортирует pptx на top-level — "
|
||
"parse_slides обязан работать без python-pptx (D4)"
|
||
)
|
||
|
||
|
||
def test_presentation_source_parses_with_canonical_parser():
|
||
mod = _load_build_module()
|
||
slides = mod.parse_slides(_read("presentation.md"))
|
||
assert len(slides) >= 12, (
|
||
f"слайдов {len(slides)}, требуется >= 12 (FR-4: ориентир 14–18)"
|
||
)
|
||
assert [s.number for s in slides] == list(range(1, len(slides) + 1)), (
|
||
"нумерация слайдов не сквозная с 1 (D4)"
|
||
)
|
||
for s in slides:
|
||
assert s.title.strip(), f"слайд {s.number}: пустой заголовок"
|
||
assert s.bullets, f"слайд {s.number}: ни одного тезиса (D4: 3–6 тезисов)"
|
||
|
||
|
||
def test_presentation_covers_mandatory_narrative_bits():
|
||
low = _read("presentation.md").lower()
|
||
for bit in ("проблем", "решени", "конвейер", "сценари", "тираж", "статус"):
|
||
assert bit in low, f"нормативный бит нарратива {bit!r} отсутствует (FR-4 / AC-7)"
|
||
|
||
|
||
def test_presentation_covers_lite_and_plane_usage_bits():
|
||
"""ORCH-105 (FR-5 / AC-4): новый обязательный контент презентации
|
||
зафиксирован анти-дрейфом — бесследное удаление слайда Lite-установки или
|
||
слайдов «как пользоваться орком через Plane» рвёт CI. Матч по lower-case
|
||
подстрокам (как `test_presentation_covers_mandatory_narrative_bits`);
|
||
маркеры — семантические корни и дословные имена статусов/лейблов (анти-
|
||
переобучение к формулировкам), все вне FORBIDDEN/секрет-эвристики."""
|
||
low = _read("presentation.md").lower()
|
||
|
||
# Слайд Lite-установки: корень `lite` + маркер развёртывания (FR-1 / AC-1).
|
||
assert "lite" in low, "слайд про Lite-установку отсутствует (FR-1 / AC-1)"
|
||
assert any(m in low for m in ("установк", "разверн")), (
|
||
"слайд Lite не несёт маркера установки/развёртывания (FR-1 / AC-1)"
|
||
)
|
||
|
||
# Слайды использования через Plane: корень `plane` + операторские маркеры.
|
||
# Точка входа, оба человеческих гейта и отмена — дословными именами статусов
|
||
# (FR-2 / AC-2; имена сверены с tech-pipeline.md / tech-integrations.md).
|
||
assert "plane" in low, "слайды использования через Plane отсутствуют (FR-2 / AC-2)"
|
||
assert "to analyse" in low, (
|
||
"слайды Plane-usage не называют точку входа «To Analyse» (FR-2 / AC-2)"
|
||
)
|
||
assert "approved" in low, (
|
||
"слайды Plane-usage не называют человеческий гейт «Approved» (FR-2 / AC-2)"
|
||
)
|
||
assert "confirm deploy" in low, (
|
||
"слайды Plane-usage не называют гейт прод-выкладки «Confirm Deploy» (FR-2 / AC-2)"
|
||
)
|
||
assert "stop" in low, (
|
||
"слайды Plane-usage не называют отмену задачи «STOP» (FR-2 / AC-2)"
|
||
)
|
||
|
||
|
||
def test_presentation_carries_reproducible_build_procedure():
|
||
text = _read("presentation.md")
|
||
assert "build_presentation.py" in text, (
|
||
"процедура сборки .pptx не ссылается на скрипт (AC-7)"
|
||
)
|
||
assert "Проверка" in text, (
|
||
"процедура сборки не несёт явных маркеров «Проверка:» (канон LITE_SETUP)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-11 (план TC-11): NFR-2 машинно — pptx не в прод-образе.
|
||
# ---------------------------------------------------------------------------
|
||
def test_no_pptx_dependency_in_prod_image():
|
||
files = sorted(REPO_ROOT.glob("requirements*")) + [REPO_ROOT / "Dockerfile"]
|
||
assert files, "requirements*/Dockerfile не найдены — скан пуст"
|
||
offenders = [
|
||
p.name for p in files if "pptx" in p.read_text(encoding="utf-8").lower()
|
||
]
|
||
assert not offenders, (
|
||
f"зависимость генерации презентации попала в прод-образ (NFR-2): {offenders}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-12 (план TC-12): указатели репо — README / CLAUDE.md / CHANGELOG (AC-9/AC-11).
|
||
# ---------------------------------------------------------------------------
|
||
def test_repo_readme_links_overview():
|
||
assert "docs/overview/" in (REPO_ROOT / "README.md").read_text(encoding="utf-8"), (
|
||
"README.md не ссылается на витрину docs/overview/ (AC-11)"
|
||
)
|
||
|
||
|
||
def test_claude_md_carries_overview_pointer_and_normative():
|
||
text = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
|
||
assert "docs/overview/" in text, "CLAUDE.md не несёт указатель на витрину (AC-9)"
|
||
|
||
|
||
def test_changelog_has_orch_011_entry():
|
||
assert "ORCH-011" in (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), (
|
||
"CHANGELOG.md не несёт docs:-записи по ORCH-011 (AC-11)"
|
||
)
|