feat: extend media roles and acceptance coverage
This commit is contained in:
@@ -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",
|
||||
];
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
|
||||
### Тёмная тема
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
'<img src="/lure/spiker.webp" alt="Блесна Спайкер">',
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user