"""Tests for pair_trips.py (brief cases T1-T9 plus input handling). Run with: python3 -m unittest test_pair_trips.py or python3 test_pair_trips.py (pytest also collects the plain test_ functions.) """ import contextlib import io import os import sys import unittest from datetime import date HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from pair_trips import BANNER, add_months, main, pair_trips, read_rows # noqa: E402 def run(body, opt=None): rows = read_rows(("date,type,port\n" + body.strip()).splitlines()) if opt: return pair_trips(rows, date.fromisoformat(opt[0]), date.fromisoformat(opt[1])) return pair_trips(rows) def counts(trips): return [(t["days_between"], t["full_days_outside"]) for t in trips] def test_t1_one_trip(): trips, flags = run("2025-12-18,Departure,SFO\n2026-01-06,Arrival,SFO") assert counts(trips) == [(19, 18)] assert trips[0]["over_5_months"] is False assert flags == [] def test_t2_unsorted_two_trips(): trips, flags = run( "2026-07-10,Arrival,JFK\n2025-12-18,Departure,SFO\n" "2026-06-20,Departure,JFK\n2026-01-06,Arrival,SFO" ) assert counts(trips) == [(19, 18), (20, 19)] assert flags == [] def test_t3_land_arrival_without_departure(): trips, flags = run( "2026-03-01,Arrival,San Ysidro\n2026-05-01,Departure,LAX\n2026-05-10,Arrival,LAX" ) assert flags == [{"flag": "arrival_without_departure", "date": date(2026, 3, 1), "port": "San Ysidro"}] assert counts(trips) == [(9, 8)] def test_t4_departure_only(): trips, flags = run("2026-09-01,Departure,ORD") assert trips == [] assert flags == [{"flag": "departure_without_return", "date": date(2026, 9, 1), "port": "ORD"}] def test_t5_over_5_months(): trips, _ = run("2026-01-15,Departure,\n2026-06-16,Arrival,") assert counts(trips) == [(152, 151)] assert trips[0]["over_5_months"] is True def test_t6_5_month_boundary(): trips, _ = run("2026-01-15,Departure,\n2026-06-15,Arrival,") assert counts(trips) == [(151, 150)] assert trips[0]["over_5_months"] is False def test_t7_same_day_trip(): # Arrival listed first on purpose: same-date Departure must sort before Arrival. trips, flags = run("2026-04-04,Arrival,\n2026-04-04,Departure,") assert counts(trips) == [(0, 0)] assert flags == [] def test_t8_opt_overlap(): trips, _ = run("2026-05-20,Departure,\n2026-06-10,Arrival,", opt=("2026-06-01", "2027-05-31")) assert counts(trips) == [(21, 20)] assert trips[0]["days_between_in_opt"] == 9 def test_t9_month_end_clamp(): trips, _ = run("2025-08-31,Departure,\n2026-02-01,Arrival,") assert counts(trips) == [(154, 153)] assert trips[0]["over_5_months"] is True assert add_months(date(2025, 8, 31), 5) == date(2026, 1, 31) def test_two_departures_in_a_row(): trips, flags = run("2026-01-01,Departure,\n2026-02-01,Departure,\n2026-02-10,Arrival,") assert counts(trips) == [(9, 8)] assert flags[0]["flag"] == "departure_without_return" and flags[0]["date"] == date(2026, 1, 1) def test_trip_outside_opt_dates_is_zero(): trips, _ = run("2025-12-18,Departure,\n2026-01-06,Arrival,", opt=("2026-06-01", "2027-05-31")) assert trips[0]["days_between_in_opt"] == 0 def test_header_case_and_short_types(): rows = read_rows([" Date , Type ,Port", "2026-01-01, d ,SFO", "", ",,", "2026-01-05,A,SFO"]) assert rows == [(date(2026, 1, 1), "Departure", "SFO"), (date(2026, 1, 5), "Arrival", "SFO")] def test_bad_input_raises(): for lines in (["2026-01-01,Departure"], ["date,type", "01/02/2026,Departure"], ["date,type", "2026-01-01,Exit"]): try: read_rows(lines) except ValueError: continue raise AssertionError(f"no error for {lines}") def test_main_on_sample_file(): out = io.StringIO() with contextlib.redirect_stdout(out): code = main([os.path.join(HERE, "sample_history.csv"), "--opt-start", "2026-06-01", "--opt-end", "2027-05-31"]) text = out.getvalue() assert code == 0 assert text.startswith(BANNER) assert "Trip 1: left 2025-12-18 SFO -> back 2026-01-06 SFO | 19 days between, 18 full days outside, 0 days in OPT dates" in text assert "Trip 2: left 2026-05-20 LAX -> back 2026-06-10 LAX | 21 days between, 20 full days outside, 9 days in OPT dates" in text assert "CHECK 2026-03-01 San Ysidro: arrival_without_departure" in text class PairTripsTests(unittest.TestCase): """Wraps every module-level test_ function so `python3 -m unittest` finds it.""" for _name, _fn in sorted(list(globals().items())): if _name.startswith("test_") and callable(_fn): setattr(PairTripsTests, _name, lambda self, _fn=_fn: _fn()) if __name__ == "__main__": unittest.main()