257 lines
11 KiB
Python
257 lines
11 KiB
Python
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")
|