File size: 11,663 Bytes
ae73c7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
"""
Tests for src/data_pbdb.py.

Two categories:
  1. Pure-function tests (occurrences_to_field, schema validation) — run
     with hand-built record lists, no network needed at all.
  2. Mocked-HTTP tests (fetch_occurrences, discover_schema, caching,
     retries). These verify the actual pagination/retry/hard-fail
     LOGIC is correct; they do not prove PBDB's real schema matches
     REQUIRED_FIELDS — that must be confirmed by running
     `python -m src.data_pbdb --discover` against the live API.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest
import torch

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.data_pbdb import (
    occurrences_to_field,
    _validate_record_schema,
    _cache_key,
    fetch_occurrences,
    discover_schema,
    get_pbdb_dataset,
    PBDBFieldDataset,
    PBDBFetchError,
    REQUIRED_FIELDS,
)
from src.provenance import SchemaValidationError, DataLoadError


@pytest.fixture
def tmpdir():
    d = tempfile.mkdtemp()
    yield d
    shutil.rmtree(d, ignore_errors=True)


def make_record(max_ma, min_ma, lng, lat, genus="Testgenus"):
    return {
        "lng": lng, "lat": lat, "max_ma": max_ma, "min_ma": min_ma,
        "genus": genus,
    }


# --------------------------------------------------------------------- #
# occurrences_to_field — pure function, no network
# --------------------------------------------------------------------- #

def test_occurrences_to_field_shape():
    records = [make_record(65, 60, 10, 20), make_record(50, 45, -30, -10)]
    field = occurrences_to_field(records, n_time_bins=4, height=8, width=8)
    assert field.shape == (4, 2, 8, 8)


def test_occurrences_to_field_places_occurrence_in_correct_cell():
    records = [make_record(max_ma=100, min_ma=100, lng=0.0, lat=0.0)]
    field = occurrences_to_field(records, n_time_bins=1, height=2, width=2,
                                  age_min=0, age_max=100)
    assert field[:, 0].sum().item() == 1.0  # exactly one occurrence counted
    assert field[:, 1].sum().item() == 1.0  # exactly one distinct genus counted


def test_occurrences_to_field_raises_on_no_usable_records():
    bad_records = [{"lng": "not-a-number", "lat": 1, "max_ma": 1, "min_ma": 1}]
    with pytest.raises(SchemaValidationError) as exc_info:
        occurrences_to_field(bad_records)
    assert exc_info.value.outcome_code == "NO_USABLE_RECORDS"


def test_occurrences_to_field_raises_on_degenerate_age_range():
    records = [make_record(50, 50, 0, 0)] * 3  # all identical age
    with pytest.raises(SchemaValidationError) as exc_info:
        occurrences_to_field(records, age_min=50, age_max=50)
    assert exc_info.value.outcome_code == "DEGENERATE_AGE_RANGE"


def test_occurrences_to_field_never_fabricates_data_in_empty_cells():
    records = [make_record(max_ma=100, min_ma=100, lng=0.0, lat=0.0)]
    field = occurrences_to_field(records, n_time_bins=5, height=10, width=10,
                                  age_min=0, age_max=100)
    nonzero_cells = (field[:, 0] > 0).sum().item()
    assert nonzero_cells == 1  # only the one real occurrence's cell is nonzero


# --------------------------------------------------------------------- #
# Schema validation
# --------------------------------------------------------------------- #

def test_validate_record_schema_raises_on_missing_fields():
    records = [{"lng": 1, "lat": 2}]  # missing max_ma, min_ma, genus
    with pytest.raises(SchemaValidationError) as exc_info:
        _validate_record_schema(records)
    assert exc_info.value.outcome_code == "PBDB_SCHEMA_MISMATCH"
    assert "max_ma" in str(exc_info.value)


def test_validate_record_schema_raises_on_empty_result():
    with pytest.raises(SchemaValidationError) as exc_info:
        _validate_record_schema([])
    assert exc_info.value.outcome_code == "EMPTY_PBDB_RESULT"


def test_validate_record_schema_passes_with_all_required_fields():
    records = [make_record(65, 60, 10, 20)]
    _validate_record_schema(records)  # should not raise


def test_validate_record_schema_passes_when_field_missing_only_from_first_record():
    records = [
        {"lng": 1, "lat": 2, "max_ma": 10, "min_ma": 5},  # genus MISSING here
        {"lng": 3, "lat": 4, "max_ma": 8, "min_ma": 4, "genus": "Canis"},
    ]
    _validate_record_schema(records)  # must NOT raise


def test_validate_record_schema_still_fails_when_field_absent_from_entire_sample():
    records = [{"lng": 1, "lat": 2, "max_ma": 10, "min_ma": 5} for _ in range(10)]
    with pytest.raises(SchemaValidationError) as exc_info:
        _validate_record_schema(records)  # genus absent from all 10
    assert exc_info.value.outcome_code == "PBDB_SCHEMA_MISMATCH"


def test_validate_record_schema_only_samples_first_50_records():
    records = [{"lng": 1, "lat": 2, "max_ma": 10, "min_ma": 5} for _ in range(60)]
    records[55]["genus"] = "Canis"  # only appears past the 50-record sample window
    with pytest.raises(SchemaValidationError):
        _validate_record_schema(records)


# --------------------------------------------------------------------- #
# Cache key determinism
# --------------------------------------------------------------------- #

def test_cache_key_is_order_independent():
    k1 = _cache_key({"base_name": "Dinosauria", "show": "full"})
    k2 = _cache_key({"show": "full", "base_name": "Dinosauria"})
    assert k1 == k2


def test_cache_key_differs_for_different_params():
    k1 = _cache_key({"base_name": "Dinosauria"})
    k2 = _cache_key({"base_name": "Mammalia"})
    assert k1 != k2


# --------------------------------------------------------------------- #
# fetch_occurrences — mocked HTTP (real network blocked in this sandbox
# by paleobiodb.org's robots.txt; these verify the LOGIC, not the live
# schema — see module docstring)
# --------------------------------------------------------------------- #

def _mock_response(records, status_code=200):
    resp = MagicMock()
    resp.status_code = status_code
    resp.json.return_value = {"records": records}
    resp.text = json.dumps({"records": records})[:200]
    return resp


def test_fetch_occurrences_paginates_until_short_page(tmpdir):
    page1 = [make_record(65, 60, i, i, genus=f"g{i}") for i in range(3)]
    page2 = [make_record(50, 45, i, i, genus=f"g{i}") for i in range(2)]  # short page

    call_count = {"n": 0}
    def fake_get(url, params=None, headers=None, timeout=None):
        call_count["n"] += 1
        if params["offset"] == 0:
            return _mock_response(page1)
        return _mock_response(page2)

    with patch("requests.get", side_effect=fake_get):
        records = fetch_occurrences(
            base_name="Testtaxon", max_records=100, page_size=3,
            cache_dir=os.path.join(tmpdir, "cache"),
        )
    assert len(records) == 5  # 3 + 2, stopped because page2 was short
    assert call_count["n"] == 2


def test_fetch_occurrences_stops_at_max_records(tmpdir):
    full_page = [make_record(65, 60, i, i, genus=f"g{i}") for i in range(5)]

    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response(full_page[: params["limit"]])

    with patch("requests.get", side_effect=fake_get):
        records = fetch_occurrences(
            base_name="Testtaxon", max_records=7, page_size=5,
            cache_dir=os.path.join(tmpdir, "cache"),
        )
    assert len(records) <= 7


def test_fetch_occurrences_hard_fails_after_retries_exhausted(tmpdir):
    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response([], status_code=500)

    with patch("requests.get", side_effect=fake_get), \
         patch("time.sleep", return_value=None):  # skip real backoff delays in test
        with pytest.raises(PBDBFetchError) as exc_info:
            fetch_occurrences(base_name="Testtaxon", cache_dir=os.path.join(tmpdir, "cache"))
    assert exc_info.value.outcome_code == "PBDB_HTTP_ERROR"


def test_fetch_occurrences_hard_fails_on_schema_mismatch(tmpdir):
    bad_records = [{"lng": 1, "lat": 2}]  # missing max_ma/min_ma/genus

    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response(bad_records)

    with patch("requests.get", side_effect=fake_get):
        with pytest.raises(SchemaValidationError) as exc_info:
            fetch_occurrences(base_name="Testtaxon", cache_dir=os.path.join(tmpdir, "cache"))
    assert exc_info.value.outcome_code == "PBDB_SCHEMA_MISMATCH"


def test_fetch_occurrences_uses_cache_on_second_call(tmpdir):
    records = [make_record(65, 60, 1, 1, genus="g1")]
    call_count = {"n": 0}

    def fake_get(url, params=None, headers=None, timeout=None):
        call_count["n"] += 1
        return _mock_response(records)

    cache_dir = os.path.join(tmpdir, "cache")
    with patch("requests.get", side_effect=fake_get):
        r1 = fetch_occurrences(base_name="Testtaxon", cache_dir=cache_dir)
        r2 = fetch_occurrences(base_name="Testtaxon", cache_dir=cache_dir)

    assert r1 == r2
    assert call_count["n"] == 1  # second call hit the cache, not the network


def test_discover_schema_reports_missing_fields(tmpdir):
    incomplete_records = [{"lng": 1, "lat": 2, "genus": "g1"}]  # no max_ma/min_ma

    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response(incomplete_records)

    with patch("requests.get", side_effect=fake_get):
        result = discover_schema(base_name="Testtaxon", limit=1)

    assert "max_ma" in result["required_fields_missing"]
    assert "genus" in result["required_fields_present"]


def test_discover_schema_hard_fails_on_empty_result(tmpdir):
    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response([])

    with patch("requests.get", side_effect=fake_get):
        with pytest.raises(PBDBFetchError) as exc_info:
            discover_schema(base_name="Nonexistenttaxon")
    assert exc_info.value.outcome_code == "PBDB_EMPTY_RESPONSE"


# --------------------------------------------------------------------- #
# End-to-end (mocked): PBDBFieldDataset / get_pbdb_dataset
# --------------------------------------------------------------------- #

def test_pbdb_field_dataset_one_trajectory_per_taxon_group(tmpdir):
    records = [make_record(max_ma=100 - i * 10, min_ma=95 - i * 10,
                            lng=i, lat=i, genus=f"g{i}") for i in range(4)]

    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response(records)

    with patch("requests.get", side_effect=fake_get):
        ds = PBDBFieldDataset(
            taxon_groups=["TaxonA", "TaxonB", "TaxonC"],
            n_time_bins=4, height=8, width=8,
            cache_dir=os.path.join(tmpdir, "cache"),
        )
    assert len(ds) == 3
    item = ds[0]
    assert item["fields"].shape == (4, 2, 8, 8)
    assert item["taxon_group"] == "TaxonA"


def test_get_pbdb_dataset_provenance_is_real_pbdb(tmpdir):
    records = [make_record(max_ma=100 - i * 10, min_ma=95 - i * 10,
                            lng=i, lat=i, genus=f"g{i}") for i in range(4)]

    def fake_get(url, params=None, headers=None, timeout=None):
        return _mock_response(records)

    with patch("requests.get", side_effect=fake_get):
        ds, provenance = get_pbdb_dataset(
            taxon_groups=["TaxonA"], cache_dir=os.path.join(tmpdir, "cache"),
        )
    assert provenance == "REAL_PBDB"
    assert len(ds) == 1


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v"]))