"""Unit tests for pure tidal phase classification — no MCP, no I/O.""" from datetime import datetime from mcnoaa_tides.tidal import ( classify_tidal_phase, interpolate_predictions, parse_hilo_predictions, ) # Synthetic hilo data: a full day with 2 highs and 2 lows RAW_HILO = [ {"t": "2026-02-21 04:30", "v": "4.521", "type": "H"}, {"t": "2026-02-21 10:42", "v": "-0.123", "type": "L"}, {"t": "2026-02-21 16:55", "v": "5.012", "type": "H"}, {"t": "2026-02-21 23:08", "v": "0.234", "type": "L"}, ] # --- parse_hilo_predictions --- def test_parse_hilo_basic(): events = parse_hilo_predictions(RAW_HILO) assert len(events) == 4 assert events[0]["type"] == "H" assert events[0]["v"] == 4.521 assert isinstance(events[0]["dt"], datetime) def test_parse_hilo_filters_non_hilo(): """Records without a 'type' field (6-min interval data) should be dropped.""" mixed = [ {"t": "2026-02-21 04:00", "v": "3.200"}, # no type — filtered out {"t": "2026-02-21 04:30", "v": "4.521", "type": "H"}, ] events = parse_hilo_predictions(mixed) assert len(events) == 1 assert events[0]["type"] == "H" def test_parse_hilo_sorts_by_time(): reversed_data = list(reversed(RAW_HILO)) events = parse_hilo_predictions(reversed_data) times = [e["dt"] for e in events] assert times == sorted(times) def test_parse_hilo_empty(): assert parse_hilo_predictions([]) == [] # --- classify_tidal_phase --- def _events(): return parse_hilo_predictions(RAW_HILO) def test_classify_ebb(): """Between high (04:30) and low (10:42) → ebb.""" now = datetime(2026, 2, 21, 7, 30) result = classify_tidal_phase(now, _events()) assert result["phase"] == "ebb" assert result["previous"]["type"] == "high" assert result["next"]["type"] == "low" assert result["progress_pct"] is not None assert 0 < result["progress_pct"] < 100 def test_classify_flood(): """Between low (10:42) and high (16:55) → flood.""" now = datetime(2026, 2, 21, 13, 0) result = classify_tidal_phase(now, _events()) assert result["phase"] == "flood" assert result["previous"]["type"] == "low" assert result["next"]["type"] == "high" def test_classify_slack_high_after(): """Within 30 min after high (04:30) → slack_high.""" now = datetime(2026, 2, 21, 4, 45) # 15 min after H result = classify_tidal_phase(now, _events()) assert result["phase"] == "slack_high" def test_classify_slack_high_before(): """Within 30 min before high (16:55) → slack_high.""" now = datetime(2026, 2, 21, 16, 30) # 25 min before H result = classify_tidal_phase(now, _events()) assert result["phase"] == "slack_high" def test_classify_slack_low_after(): """Within 30 min after low (10:42) → slack_low.""" now = datetime(2026, 2, 21, 11, 0) # 18 min after L result = classify_tidal_phase(now, _events()) assert result["phase"] == "slack_low" def test_classify_slack_low_before(): """Within 30 min before low (23:08) → slack_low.""" now = datetime(2026, 2, 21, 22, 50) # 18 min before L result = classify_tidal_phase(now, _events()) assert result["phase"] == "slack_low" def test_classify_progress_midpoint(): """At the midpoint between two events, progress should be ~50%.""" events = _events() # Midpoint between 04:30 H and 10:42 L = ~07:36 mid = datetime(2026, 2, 21, 7, 36) result = classify_tidal_phase(mid, events) assert result["progress_pct"] is not None assert 45 < result["progress_pct"] < 55 def test_classify_minutes_timing(): """Check that minutes_since and minutes_to are reasonable.""" now = datetime(2026, 2, 21, 7, 30) # 3h after H at 04:30 result = classify_tidal_phase(now, _events()) assert result["minutes_since_previous"] == 180 # 3 hours assert result["minutes_to_next"] is not None assert result["minutes_to_next"] > 0 def test_classify_before_all_events(): """Timestamp before all hilo events — no previous event.""" now = datetime(2026, 2, 21, 1, 0) # Before 04:30 H result = classify_tidal_phase(now, _events()) assert result["previous"] is None assert result["next"] is not None assert result["phase"] in ("flood", "ebb") def test_classify_after_all_events(): """Timestamp after all hilo events — no next event.""" now = datetime(2026, 2, 22, 3, 0) # After 23:08 L result = classify_tidal_phase(now, _events()) assert result["previous"] is not None assert result["next"] is None assert result["phase"] in ("flood", "ebb") def test_classify_empty_events(): result = classify_tidal_phase(datetime(2026, 2, 21, 12, 0), []) assert result["phase"] == "unknown" # --- interpolate_predictions --- def test_interpolate_midpoint(): """Interpolation at midpoint should return average of bracketing values.""" times = [datetime(2026, 2, 21, 0, 0), datetime(2026, 2, 21, 1, 0)] values = [2.0, 4.0] result = interpolate_predictions(datetime(2026, 2, 21, 0, 30), times, values) assert result is not None assert abs(result - 3.0) < 0.01 def test_interpolate_at_boundary(): """Interpolation exactly at a prediction point should return that value.""" times = [datetime(2026, 2, 21, 0, 0), datetime(2026, 2, 21, 1, 0)] values = [2.0, 4.0] result = interpolate_predictions(times[0], times, values) assert result is not None assert abs(result - 2.0) < 0.01 def test_interpolate_outside_window(): """Timestamp outside prediction window should return None.""" times = [datetime(2026, 2, 21, 0, 0), datetime(2026, 2, 21, 1, 0)] values = [2.0, 4.0] assert interpolate_predictions(datetime(2026, 2, 20, 23, 0), times, values) is None assert interpolate_predictions(datetime(2026, 2, 21, 2, 0), times, values) is None def test_interpolate_multiple_segments(): """Interpolation across multiple 6-min segments.""" times = [ datetime(2026, 2, 21, 0, 0), datetime(2026, 2, 21, 0, 6), datetime(2026, 2, 21, 0, 12), ] values = [2.0, 3.0, 5.0] # At 0:03 (midpoint of first segment) → 2.5 result = interpolate_predictions(datetime(2026, 2, 21, 0, 3), times, values) assert result is not None assert abs(result - 2.5) < 0.01 # At 0:09 (midpoint of second segment) → 4.0 result = interpolate_predictions(datetime(2026, 2, 21, 0, 9), times, values) assert result is not None assert abs(result - 4.0) < 0.01 def test_interpolate_insufficient_data(): """Less than 2 prediction points → None.""" assert interpolate_predictions(datetime(2026, 2, 21, 0, 0), [], []) is None assert interpolate_predictions( datetime(2026, 2, 21, 0, 0), [datetime(2026, 2, 21, 0, 0)], [2.0], ) is None