Files
rf4-spotter/tests/test_community_sources.py
T

257 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime, time, timezone
from pathlib import Path
import pytest
from rf4_research.community_sources import (
CommunityParseError,
parse_rf4db_catches,
parse_rf4db_detail,
parse_rf4db_waterbodies,
parse_rf4db_waterbody_detail,
parse_rf4map_point,
parse_rf4map_waterbodies,
parse_rf4posts_spot,
parse_rf4stat_fishing,
parse_rf4stat_posts,
parse_rf4stat_waterbodies,
)
from rf4_research.waterbody_crosswalk import (
CanonicalWaterbody, WaterbodyIdentity, missing_external_ids,
identities_from_candidates, suggest_waterbody_crosswalk,
)
FIXTURES = Path(__file__).parent / "fixtures"
def fixture(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
def test_parses_rf4db_public_catch_cards() -> None:
row = parse_rf4db_catches(fixture("rf4db_catches_sample.html"))[0]
assert (row.source_system, row.source_external_id) == ("rf4db", "11111111-1111-4111-8111-111111111111")
assert (row.fish, row.fish_external_id) == ("Щука обыкновенная", "pike")
assert (row.waterbody, row.waterbody_external_id) == ("Тестовое озеро", "test-lake")
assert (row.x, row.y, row.weight_g) == (71, 92, None)
assert row.game_time == time(6, 14)
assert row.water_temperature_c == 12.5
def test_parses_rf4db_detail_equipment_and_cast_facts() -> None:
detail = parse_rf4db_detail(
fixture("rf4db_detail_sample.html"),
source_url="https://rf4db.com/ru/catches/11111111-1111-4111-8111-111111111111",
)
assert detail.source_external_id == "11111111-1111-4111-8111-111111111111"
assert (detail.wind, detail.line_release_m, detail.clip_m, detail.cast_direction_deg) == ("SE 4 м/с", 5.1, 5.0, 181.1)
assert [(item.kind, item.name, item.external_id) for item in detail.equipment] == [
("Приманка поклёвки", "Тестовая приманка", "test-lure"),
("Катушка", "Тестовая катушка", "test-reel"),
]
def test_rf4db_detail_rejects_unrelated_html() -> None:
with pytest.raises(CommunityParseError, match="detail not found"):
parse_rf4db_detail("<html></html>", source_url="https://rf4db.com/ru/catches/id")
def test_parses_rf4db_waterbody_catalog_without_inventing_image_roles() -> None:
rows = parse_rf4db_waterbodies(fixture("rf4db_waterbodies_sample.html"), expected_count=2)
assert len(rows) == 2
assert (rows[0].source_external_id, rows[0].name) == ("level_001_mosquito", "оз. Комариное")
assert (rows[0].unlock_level, rows[0].unlock_label, rows[0].fish_species_count) == (1, "1", 20)
assert rows[0].source_url == "https://rf4db.com/ru/maps/level_001_mosquito"
assert rows[0].image_url == "https://oss.rf4db.com/game/maps/level_001_mosquito.webp"
assert (rows[1].unlock_level, rows[1].unlock_label) == (None, "Старт")
assert rows[1].image_url == "https://rf4db.com/game/maps/level_000_cottage.webp"
def test_parses_rf4map_waterbody_directory_as_secondary_candidates() -> None:
rows = parse_rf4map_waterbodies(fixture("rf4map_waterbodies_sample.html"))
assert [(row.source_external_id, row.name, row.fish_species_count) for row in rows] == [
("12", "р. Нижняя Тунгуска", 38),
("16", "оз. Комариное", None),
]
assert rows[0].source_system == "rf4map-waterbodies"
assert rows[0].source_url == "https://rf4map.ru/lakes/12"
def test_rf4map_waterbody_directory_rejects_unrelated_html() -> None:
with pytest.raises(CommunityParseError, match="RF4MAP waterbody directory not found"):
parse_rf4map_waterbodies("<html></html>")
def test_parses_rf4stat_waterbody_directory_metrics() -> None:
rows = parse_rf4stat_waterbodies(fixture("rf4stat_waterbodies_sample.html"))
assert [(row.source_external_id, row.name, row.catches_count, row.fish_species_count, row.bait_count, row.spot_count) for row in rows] == [
("1", "Mosquito Lake", 254, 20, 42, 9),
("19", "Elk Lake", 520, 19, 121, 11),
]
assert rows[0].source_url == "https://en.rf4-stat.ru/locations/location/1"
def test_rf4stat_waterbody_directory_rejects_unrelated_html() -> None:
with pytest.raises(CommunityParseError, match="RF4-STAT waterbody directory not found"):
parse_rf4stat_waterbodies("<html></html>")
def test_secondary_candidates_convert_to_crosswalk_identities_only() -> None:
candidates = parse_rf4map_waterbodies(fixture("rf4map_waterbodies_sample.html"))
identities = identities_from_candidates(candidates)
assert identities == [
WaterbodyIdentity("rf4map-waterbodies", "12", "р. Нижняя Тунгуска"),
WaterbodyIdentity("rf4map-waterbodies", "16", "оз. Комариное"),
]
def test_rf4db_waterbody_catalog_rejects_unrelated_html() -> None:
with pytest.raises(CommunityParseError, match="waterbody cards not found"):
parse_rf4db_waterbodies("<html></html>")
def test_rf4db_waterbody_catalog_rejects_partial_results() -> None:
with pytest.raises(CommunityParseError, match="expected 19, got 2"):
parse_rf4db_waterbodies(fixture("rf4db_waterbodies_sample.html"))
def test_parses_rf4db_waterbody_detail_without_assigning_media_roles() -> None:
row = parse_rf4db_waterbody_detail(
fixture("rf4db_waterbody_detail_sample.html"),
source_url="https://rf4db.com/ru/maps/level_001_mosquito",
)
assert (row.source_external_id, row.name) == ("level_001_mosquito", "оз. Комариное")
assert row.description == "Большое озеро с каменистыми берегами."
assert row.aliases == ("Комариное",)
assert row.fish_species == ("Щука", "Окунь")
assert row.fish_external_ids == ("pike", "perch")
assert row.image_urls == (
"https://oss.rf4db.com/game/maps/level_001_mosquito.webp",
"https://rf4db.com/game/maps/level_001_mosquito-depth.webp",
)
assert row.point_urls == ("https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34",)
def test_parses_live_waterbody_fish_links_without_fish_media() -> None:
row = parse_rf4db_waterbody_detail(
fixture("rf4db_waterbody_detail_live_sample.html"),
source_url="https://rf4db.com/ru/maps/level_000_home",
)
assert row.fish_species == ("Лягушка", "Ротан", "Окунь")
assert row.fish_external_ids == ("frog", "a.sleeper", "perch")
assert row.image_urls == (
"https://oss.rf4db.com/game/maps/level_000_home.png",
"https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp",
)
def test_rf4db_waterbody_detail_rejects_missing_fish_list() -> None:
with pytest.raises(CommunityParseError, match="detail not found or incomplete"):
parse_rf4db_waterbody_detail(
"<main><h1>Озеро</h1></main>",
source_url="https://rf4db.com/ru/maps/level_001_mosquito",
)
def test_waterbody_crosswalk_only_suggests_unique_exact_matches() -> None:
canonical = [
CanonicalWaterbody("mosquito", "оз. Комариное", ("Комариное",)),
CanonicalWaterbody("ambiguous-a", "Большое озеро"),
CanonicalWaterbody("ambiguous-b", "Другое", ("Большое озеро",)),
]
rows = suggest_waterbody_crosswalk(canonical, [
WaterbodyIdentity("rf4db", "level_001_mosquito", "КОМАРИНОЕ"),
WaterbodyIdentity("rf4map", "16", "Большое озеро"),
WaterbodyIdentity("rf4-posts", "lake-x", "Норвежское море"),
])
assert [(row.status, row.canonical_keys) for row in rows] == [
("exact", ("mosquito",)), ("ambiguous", ()), ("unmatched", ()),
]
def test_missing_source_ids_are_diagnostic_not_withdrawals() -> None:
assert missing_external_ids({"16", "17", "18"}, {"16"}) == ("17", "18")
def test_parses_rf4stat_fishing_rows() -> None:
row = parse_rf4stat_fishing(
fixture("rf4stat_fishing_sample.html"),
now=datetime(2026, 9, 3, 12, tzinfo=timezone.utc),
)[0]
assert (row.source_system, row.source_external_id) == ("rf4stat-fishing", "123")
assert (row.x, row.y, row.weight_g) == (71, 92, 11_584)
assert row.coordinate_raw == "71:92"
assert row.published_at == datetime(2026, 9, 3, 11, 27, tzinfo=timezone.utc)
assert (row.player_name, row.clip, row.fishing_style) == ("Игрок", "35", "Донная")
def test_parses_rf4stat_posts_without_using_locked_coordinates() -> None:
row = parse_rf4stat_posts(fixture("rf4stat_posts_sample.html"))[0]
assert (row.source_system, row.source_external_id) == ("rf4stat-post", "456:0")
assert (row.x, row.y, row.weight_g) == (None, None, 4_321)
assert row.coordinate_raw == "XX:XX"
assert row.evidence_urls == ("https://img.example.test/proof.jpg",)
def test_parses_rf4map_individual_observations_into_common_contract() -> None:
rows = parse_rf4map_point(
fixture("rf4map_point_sample.html"), source_url="https://rf4map.ru/points/275",
)
assert len(rows) == 2
assert (rows[0].source_system, rows[0].source_external_id) == ("rf4map", "37100")
assert (rows[0].fish, rows[0].fish_external_id) == ("Линь", "98")
assert (rows[0].waterbody, rows[0].waterbody_external_id) == ("оз. Комариное", "16")
assert (rows[0].x, rows[0].y, rows[0].weight_g, rows[0].clip) == (53, 87, None, "8")
assert rows[1].bait == "Тесто медовое"
assert rows[1].evidence_urls == ("https://img.example.test/proof.jpg",)
def test_parses_rf4posts_aggregate_spot_without_inventing_weights() -> None:
rows = parse_rf4posts_spot(
fixture("rf4posts_spot_sample.html"),
source_url="https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee",
)
assert [row.fish for row in rows] == ["Карп чешуйчатый", "Линь"]
assert rows[0].source_external_id.endswith(":carp")
assert (rows[0].x, rows[0].y, rows[0].weight_g) == (132, 147, None)
assert (rows[0].rig_type, rows[0].fishing_style, rows[0].clip) == ("method_popup", "feeder", "8")
assert rows[0].evidence_urls == ("https://img.example.test/spot.png",)
def test_new_sources_keep_source_namespaces_for_compatible_records() -> None:
rf4map = parse_rf4map_point(
fixture("rf4map_point_sample.html"), source_url="https://rf4map.ru/points/275",
)[0]
rf4posts = parse_rf4posts_spot(
fixture("rf4posts_spot_sample.html"), source_url="https://rf4-posts.com/ru/spots/example",
)[1]
assert rf4map.fish == rf4posts.fish == "Линь"
assert rf4map.source_system != rf4posts.source_system
assert rf4map.source_external_id != rf4posts.source_external_id
@pytest.mark.parametrize("parser", [parse_rf4db_catches, parse_rf4stat_fishing, parse_rf4stat_posts])
def test_parsers_reject_unrelated_html(parser) -> None:
with pytest.raises(CommunityParseError):
parser("<html><body>not a source page</body></html>")
@pytest.mark.parametrize("parser", [parse_rf4map_point, parse_rf4posts_spot])
def test_detail_parsers_reject_unrelated_html(parser) -> None:
with pytest.raises(CommunityParseError):
parser("<html><body>not a source page</body></html>", source_url="https://example.test/item/1")