From a547d09fcd6b7e3d33b4aa29d71dde8e1d0ea56e Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 18:30:16 +0700 Subject: [PATCH] feat: extend media roles and acceptance coverage --- apps/web/tests/visual-matrix.spec.ts | 2 +- docs/ROADMAP.md | 4 ++-- rf4_research/media_assets.py | 15 +++++++++++++-- rf4_research/media_cli.py | 3 ++- scripts/capture-visual-matrix.mjs | 2 +- tests/test_media_assets.py | 23 +++++++++++++++++++++++ 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/visual-matrix.spec.ts b/apps/web/tests/visual-matrix.spec.ts index fb75830..8862bc9 100644 --- a/apps/web/tests/visual-matrix.spec.ts +++ b/apps/web/tests/visual-matrix.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; const routes = [ - "/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules", + "/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules", "/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation", "/admin/external-sources", "/admin/media", ]; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 11c551d..1ad3bf2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -73,8 +73,8 @@ - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом. -- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance. -- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. +- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. +- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов и малой выборки рекомендаций; каталог `/tackle` включён в visual-matrix regression. Offline catalog/media audits и сохранение старых данных при сбое проходят. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов и query-plan gate для production-фильтров. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. ### Тёмная тема diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index 3f18444..a490c8e 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -18,6 +18,13 @@ DERIVATIVE_TARGETS = {"card": 256, "detail": 1024} WATERBODY_MEDIA_ROLES = frozenset({ "waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot", }) +TACKLE_MEDIA_ROLES = frozenset({ + "tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot", +}) +MEDIA_ROLES_BY_ENTITY = { + "waterbody": WATERBODY_MEDIA_ROLES, + "tackle": TACKLE_MEDIA_ROLES, +} @dataclass(frozen=True, slots=True) @@ -276,8 +283,8 @@ def review_asset( item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None) if item is None: raise ValueError("asset URL is not present in manifest") - if media_role is not None and (entity_type != "waterbody" or media_role not in WATERBODY_MEDIA_ROLES): - raise ValueError("media role is only valid for a waterbody and must be a known waterbody role") + if media_role is not None and media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type or "", set()): + raise ValueError(f"media role is only valid for {entity_type}; use a known {entity_type} role") if decision == "approved": if item.get("status") != "stored": raise ValueError("only a stored asset can be approved") @@ -345,6 +352,10 @@ def audit_media_catalog(root: Path) -> dict: issues.append(f"{item['asset_url']}: derivative {exc}") if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}): issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping") + if item.get("media_role") not in {None, *WATERBODY_MEDIA_ROLES, *TACKLE_MEDIA_ROLES}: + issues.append(f"{item['asset_url']}: unknown media role") + elif item.get("media_role") is not None and item.get("media_role") not in MEDIA_ROLES_BY_ENTITY.get(item.get("entity_type"), set()): + issues.append(f"{item['asset_url']}: media role does not match entity type") files_root = root / "files" orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else [] return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned} diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 23a84e8..2cce159 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -192,6 +192,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--decision", choices=("approved", "rejected")) parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference")) parser.add_argument("--entity-key") + parser.add_argument("--media-role", help="Explicit reviewed role for waterbody/tackle media") parser.add_argument("--note") parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access") parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") @@ -232,7 +233,7 @@ def main(argv: list[str] | None = None) -> int: if args.review_url: if not args.decision: parser.error("--decision is required with --review-url") - item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note) + item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note, media_role=args.media_role) print(f"{item['status']} {item['asset_url']}") return 0 if args.reclassify: diff --git a/scripts/capture-visual-matrix.mjs b/scripts/capture-visual-matrix.mjs index fc09785..0242e99 100755 --- a/scripts/capture-visual-matrix.mjs +++ b/scripts/capture-visual-matrix.mjs @@ -6,7 +6,7 @@ import process from "node:process"; import { chromium } from "../apps/web/node_modules/playwright/index.mjs"; const routes = [ - "/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules", + "/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules", "/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation", "/admin/external-sources", "/admin/media", ]; diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index 3fb7103..87f6949 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -173,6 +173,29 @@ def test_waterbody_media_role_rejects_unknown_role(tmp_path: Path) -> None: ) +def test_tackle_media_roles_require_explicit_review_and_matching_entity(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.json" + candidate = extract_media_candidates( + 'Блесна Спайкер', + source_page="https://rf4db.com/ru/wiki/lures/spiker", + )[0] + merge_manifest(manifest, [candidate]) + with pytest.raises(ValueError, match="known tackle role"): + review_asset( + manifest, asset_url=candidate.asset_url, decision="rejected", + entity_type="tackle", entity_key="tackle:spiker", media_role="waterbody_map", + ) + + saved = json.loads(manifest.read_text(encoding="utf-8")) + saved["assets"][0].update({"status": "stored", "sha256": "a" * 64, "local_path": "files/spiker.webp", "width": 256, "height": 256, "content_type": "image/webp"}) + manifest.write_text(json.dumps(saved), encoding="utf-8") + reviewed = review_asset( + manifest, asset_url=candidate.asset_url, decision="approved", + entity_type="tackle", entity_key="tackle:spiker", media_role="tackle_card", + ) + assert reviewed["media_role"] == "tackle_card" + + def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None: with pytest.raises(ValueError, match="valid raster"): inspect_image(b"not an image")