feat: preserve gear components through catch imports
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-20 18:10:41 +07:00
parent 4e9895fbf4
commit 4f0c2d23de
15 changed files with 379 additions and 7 deletions
@@ -0,0 +1,40 @@
"""preserve ordered gear evidence on catches
Revision ID: 0022
Revises: 0021
"""
from alembic import op
import sqlalchemy as sa
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"catch_tackle_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200), nullable=False),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("catch_report_id", "position"),
sa.CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
def downgrade() -> None:
op.drop_table("catch_tackle_component")
+13
View File
@@ -11,6 +11,8 @@ from .models import (
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
Fish, ModerationStatus, SourceType, Spot, Waterbody,
)
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
class ExternalReviewError(ValueError):
@@ -118,6 +120,17 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
setattr(report, key, value)
session.add(report)
session.flush()
replace_tackle_components(
session,
report,
from_catch_fields(
bait=observation.payload.get("bait"),
rig_type=observation.payload.get("rig_type"),
),
source_system=observation.source_system,
source_url=observation.source_url,
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
)
observation.catch_report = report
observation.status = "published"
observation.reviewed_at = now
+10
View File
@@ -16,6 +16,8 @@ from .models import (
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody,
)
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
))
run.rows_updated += 1
replace_tackle_components(
session,
report,
from_catch_fields(bait=raw.bait, rig_type=None),
source_system="rf4-official",
source_url=url,
raw_payload={"origin": "official_record", "source_external_id": key},
)
run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc)
session.commit()
+28
View File
@@ -165,6 +165,34 @@ class CatchReport(Base):
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship()
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
class CatchTackleComponent(Base):
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
__tablename__ = "catch_tackle_component"
__table_args__ = (
UniqueConstraint("catch_report_id", "position"),
CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str] = mapped_column(String(200))
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSON)
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
tackle_item: Mapped[TackleItem | None] = relationship()
rig: Mapped[Rig | None] = relationship()
class OfficialRecordImport(Base):
+10
View File
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
from ..schemas import CatchReportAccepted, CatchReportCreate
from ..storage import ScreenshotError, upload_screenshot
from ..submission_security import check_rate_limit
from ..tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
router = APIRouter()
logger = logging.getLogger("rf4.api.submissions")
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report)
replace_tackle_components(
db,
report,
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
source_system="user",
source_url=payload.source_url,
raw_payload={"origin": "user_submission"},
)
if key_hash:
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
try:
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete
from sqlalchemy.orm import Session
from rf4_research.gear_components import GearComponentIdentity
from .models import CatchReport, CatchTackleComponent
def replace_tackle_components(
session: Session,
report: CatchReport,
components: Iterable[GearComponentIdentity],
*,
source_system: str | None,
source_url: str | None = None,
raw_payload: dict | None = None,
) -> None:
"""Replace the ordered evidence for a report while keeping imports idempotent."""
session.flush()
session.execute(
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
)
session.add_all(
CatchTackleComponent(
catch_report_id=report.id,
role=component.role,
position=component.position,
raw_value=component.raw_value,
source_system=source_system,
source_external_id=component.source_external_id,
source_url=source_url,
raw_payload=raw_payload,
)
for component in components
)
+5 -2
View File
@@ -12,7 +12,7 @@ from app.database import Base, get_session
from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning
from app.main import app
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.routers import admin as admin_router
@@ -351,11 +351,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
assert created.status_code == 201
assert created.headers["Cache-Control"] == "no-store"
assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"]
with Session(engine) as db:
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
fish = Fish(slug="pike", name_ru="Щука")
waterbody = Waterbody(slug="lake", name_ru="Озеро")
report = CatchReport(
fish=fish, waterbody=waterbody, weight_g=1000,
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
source_confidence=50, moderation_status=ModerationStatus.pending,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
])
session.add(report)
session.commit()
saved = session.get(CatchReport, report.id)
assert saved is not None
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
]
+3 -1
View File
@@ -11,7 +11,7 @@ from app.community_importer import CommunityImportError, stage_observations, upd
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.source_lifecycle import record_scheduled_source_check, record_source_check
from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -239,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
assert updated.id == report_id
assert updated.weight_g == 6000
assert updated.moderation_status.value == "approved"
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
+2 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import Base
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
# A12: Second import of identical data creates no events (no fields changed)
assert (second.rows_created, second.rows_updated) == (0, 0)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
+3 -3
View File
@@ -68,9 +68,9 @@
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Остаётся подключить эти сущности к import/review/API и выполнить безопасный backfill только после реального crosswalk.
- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять.
- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки.
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
- [ ] **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 и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников.
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть.
- [ ] **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.
+46
View File
@@ -0,0 +1,46 @@
"""Ordered gear evidence extracted from source records without auto-mapping."""
from __future__ import annotations
from dataclasses import dataclass
from .community_sources import EquipmentItem
@dataclass(frozen=True, slots=True)
class GearComponentIdentity:
role: str
position: int
raw_value: str
source_external_id: str | None
def _role_from_kind(kind: str) -> str:
value = kind.casefold()
terms = {
"удилищ": "rod", "катуш": "reel", "леск": "line", "крюч": "hook",
"поплав": "float", "груз": "sinker", "монтаж": "rig", "rig": "rig",
"приманк": "lure", "нажив": "bait", "bait": "bait", "lure": "lure",
}
return next((role for term, role in terms.items() if term in value), "other")
def from_equipment(items: tuple[EquipmentItem, ...]) -> tuple[GearComponentIdentity, ...]:
"""Keep source order and raw text; canonical mapping happens only after review."""
return tuple(
GearComponentIdentity(
role=_role_from_kind(item.kind), position=index,
raw_value=item.name, source_external_id=item.external_id,
)
for index, item in enumerate(items)
if item.name.strip()
)
def from_catch_fields(*, bait: str | None, rig_type: str | None) -> tuple[GearComponentIdentity, ...]:
values: list[GearComponentIdentity] = []
if bait and bait.strip():
values.append(GearComponentIdentity("lure", len(values), bait.strip(), None))
if rig_type and rig_type.strip():
values.append(GearComponentIdentity("rig", len(values), rig_type.strip(), None))
return tuple(values)
+86
View File
@@ -0,0 +1,86 @@
"""Conservative offline crosswalk suggestions for gear identities."""
from __future__ import annotations
from dataclasses import dataclass
from .community_sources import GEAR_CATEGORIES
from .media_assets import normalize_entity_label
@dataclass(frozen=True, slots=True)
class CanonicalGear:
key: str
name: str
category: str
aliases: tuple[str, ...] = ()
brand: str | None = None
family: str | None = None
@dataclass(frozen=True, slots=True)
class GearIdentity:
source_system: str
external_id: str
name: str
category: str
brand: str | None = None
family: str | None = None
@dataclass(frozen=True, slots=True)
class GearCrosswalkSuggestion:
source_system: str
external_id: str
external_name: str
category: str
status: str
canonical_keys: tuple[str, ...]
def _compatible_category(value: str) -> bool:
return value.casefold().strip() in GEAR_CATEGORIES
def suggest_gear_crosswalk(
canonical: list[CanonicalGear], identities: list[GearIdentity],
) -> list[GearCrosswalkSuggestion]:
"""Suggest only unique exact normalized-name matches in the same category.
Brand/family differences never create an automatic match. Ambiguous,
category-mismatched and unmatched identities retain no canonical key.
"""
by_name: dict[str, set[str]] = {}
by_key = {item.key: item for item in canonical}
for item in canonical:
if not _compatible_category(item.category):
raise ValueError(f"invalid canonical gear category: {item.category!r}")
for name in (item.name, *item.aliases):
by_name.setdefault(normalize_entity_label(name), set()).add(item.key)
result: list[GearCrosswalkSuggestion] = []
for identity in identities:
keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set())))
compatible = tuple(
key for key in keys
if by_key[key].category.casefold() == identity.category.casefold()
and not (by_key[key].brand and identity.brand and normalize_entity_label(by_key[key].brand) != normalize_entity_label(identity.brand))
and not (by_key[key].family and identity.family and normalize_entity_label(by_key[key].family) != normalize_entity_label(identity.family))
)
if not _compatible_category(identity.category):
status = "category_mismatch"
selected: tuple[str, ...] = ()
elif len(compatible) == 1:
status = "exact"
selected = compatible
elif len(compatible) > 1:
status = "ambiguous"
selected = ()
else:
status = "unmatched"
selected = ()
result.append(GearCrosswalkSuggestion(
source_system=identity.source_system, external_id=identity.external_id,
external_name=identity.name, category=identity.category,
status=status, canonical_keys=selected,
))
return result
+20
View File
@@ -0,0 +1,20 @@
from rf4_research.community_sources import EquipmentItem
from rf4_research.gear_components import from_catch_fields, from_equipment
def test_from_equipment_preserves_source_order_and_external_ids() -> None:
rows = from_equipment((
EquipmentItem("Катушка", "Test Reel", "reel-1"),
EquipmentItem("Приманка поклёвки", "Test Lure", "lure-1"),
))
assert [(row.role, row.position, row.raw_value, row.source_external_id) for row in rows] == [
("reel", 0, "Test Reel", "reel-1"),
("lure", 1, "Test Lure", "lure-1"),
]
def test_from_catch_fields_keeps_unresolved_raw_values() -> None:
rows = from_catch_fields(bait="Spiker #2", rig_type="Method Popup")
assert [(row.role, row.raw_value, row.source_external_id) for row in rows] == [
("lure", "Spiker #2", None), ("rig", "Method Popup", None),
]
+43
View File
@@ -0,0 +1,43 @@
import pytest
from rf4_research.gear_crosswalk import (
CanonicalGear,
GearIdentity,
suggest_gear_crosswalk,
)
def test_gear_crosswalk_matches_exact_name_and_category_only() -> None:
canonical = [
CanonicalGear("lure:spiker-2", "Spiker #2", "lure", ("Spiker 2",), brand="RF4"),
CanonicalGear("rig:method-popup", "Method Popup", "rig"),
]
identities = [
GearIdentity("rf4db", "spiker-2", " spiker #2 ", "lure", brand="RF4"),
GearIdentity("rf4db", "method-popup", "Method Popup", "lure"),
GearIdentity("rf4db", "unknown", "Unknown bait", "bait"),
]
rows = suggest_gear_crosswalk(canonical, identities)
assert [(row.status, row.canonical_keys) for row in rows] == [
("exact", ("lure:spiker-2",)), ("unmatched", ()), ("unmatched", ()),
]
def test_gear_crosswalk_does_not_auto_select_ambiguous_or_brand_mismatch() -> None:
canonical = [
CanonicalGear("a", "Test Hook", "hook", brand="A"),
CanonicalGear("b", "Test Hook", "hook", brand="B"),
]
identities = [
GearIdentity("rf4db", "1", "Test Hook", "hook"),
GearIdentity("rf4db", "2", "Test Hook", "hook", brand="C"),
GearIdentity("rf4db", "3", "Test Hook", "float"),
]
rows = suggest_gear_crosswalk(canonical, identities)
assert [row.status for row in rows] == ["ambiguous", "unmatched", "unmatched"]
assert all(row.canonical_keys == () for row in rows)
def test_gear_crosswalk_rejects_invalid_canonical_category() -> None:
with pytest.raises(ValueError, match="invalid canonical gear category"):
suggest_gear_crosswalk([CanonicalGear("bad", "Bad", "equipment")], [])