""" tests/test_arbiter.py — Unit tests for PolicyArbiter. These tests exercise the merge logic in isolation using pure fixture data, with no LLM calls or file I/O. Run with: pytest tests/test_arbiter.py -v (From project root with the virtual-env activated.) """ from __future__ import annotations import sys from pathlib import Path # Allow importing from src/ without installing the package sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import pytest from arbiter import PolicyArbiter from schema import ( AdditionalRiskData, ConflictEntry, CoverAndExcesses, Driver, ExcessBreakdown, FinancialSummary, NoClaimsDiscount, OptionalExtras, PeriodOfCover, PolicyHeader, UKMotorGoldenRecord, VehicleDetails, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_schedule( policy_number: str = "POL-001", insurer: str = "TestInsurer Ltd", cover_type: str = "Comprehensive", ncb_years: int = 3, class_of_use: str | None = None, drivers: list[dict] | None = None, excess_compulsory: float = 250.0, excess_voluntary: float = 150.0, premium: float = 600.0, vrm: str = "AB12 XYZ", ) -> UKMotorGoldenRecord: drv_list = [ Driver(**d) for d in (drivers or [{"name": "ALICE SMITH", "is_main_driver": True}]) ] return UKMotorGoldenRecord( policy_header=PolicyHeader(policy_number=policy_number, insurer=insurer), vehicle_details=VehicleDetails(vrm=vrm, make="Toyota", model="Corolla"), driver_details=drv_list, cover_and_excesses=CoverAndExcesses( cover_type=cover_type, class_of_use=class_of_use, no_claims_discount=NoClaimsDiscount(years=ncb_years, protected=False), excess_breakdown=ExcessBreakdown( standard_compulsory=excess_compulsory, voluntary=excess_voluntary, total_accidental_damage=excess_compulsory + excess_voluntary, ), ), financial_summary=FinancialSummary( total_annual_premium=premium, optional_extras=OptionalExtras(), ), additional_risk_data=AdditionalRiskData(home_ownership="Owned"), ) def _make_certificate( policy_number: str = "POL-001", class_of_use: str = "Social, Domestic and Pleasure", driving_other_cars: bool = False, drivers: list[dict] | None = None, insurer: str | None = None, ) -> UKMotorGoldenRecord: drv_list = [ Driver(**d) for d in (drivers or [{"name": "ALICE SMITH", "is_main_driver": True}]) ] return UKMotorGoldenRecord( policy_header=PolicyHeader( policy_number=policy_number, insurer=insurer, ), driver_details=drv_list, cover_and_excesses=CoverAndExcesses( class_of_use=class_of_use, driving_other_cars=driving_other_cars, ), ) # --------------------------------------------------------------------------- # Basic merge tests # --------------------------------------------------------------------------- class TestBasicMerge: def test_returns_tuple_with_conflicts_list(self): arbiter = PolicyArbiter() sched = _make_schedule() cert = _make_certificate() result = arbiter.merge_records(sched, "sched.pdf", cert, "cert.pdf") assert isinstance(result, tuple) golden, conflicts = result assert isinstance(conflicts, list) def test_vehicle_details_from_schedule(self): arbiter = PolicyArbiter() sched = _make_schedule(vrm="AB12 XYZ") cert = _make_certificate() golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.vehicle_details is not None assert golden.vehicle_details.vrm == "AB12 XYZ" def test_class_of_use_from_certificate(self): arbiter = PolicyArbiter() sched = _make_schedule(class_of_use="Social") # schedule has one cert = _make_certificate(class_of_use="Social, Domestic and Pleasure") # cert is master golden, conflicts = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.cover_and_excesses.class_of_use == "Social, Domestic and Pleasure" def test_cover_type_from_schedule(self): arbiter = PolicyArbiter() sched = _make_schedule(cover_type="Comprehensive") cert = _make_certificate() golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.cover_and_excesses.cover_type == "Comprehensive" def test_financial_summary_from_schedule(self): arbiter = PolicyArbiter() sched = _make_schedule(premium=750.0) cert = _make_certificate() golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.financial_summary.total_annual_premium == 750.0 def test_additional_risk_data_from_schedule(self): arbiter = PolicyArbiter() sched = _make_schedule() cert = _make_certificate() golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.additional_risk_data.home_ownership == "Owned" # --------------------------------------------------------------------------- # One-sided merge (missing Schedule or Certificate) # --------------------------------------------------------------------------- class TestOneSidedMerge: def test_empty_schedule_uses_certificate_drivers(self): arbiter = PolicyArbiter() sched = UKMotorGoldenRecord() # empty cert = _make_certificate( drivers=[{"name": "BOB JONES", "is_main_driver": True}] ) golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert len(golden.driver_details) == 1 assert golden.driver_details[0].name == "BOB JONES" def test_empty_certificate_still_merges(self): arbiter = PolicyArbiter() sched = _make_schedule() cert = UKMotorGoldenRecord() # empty golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.vehicle_details is not None assert golden.cover_and_excesses is not None def test_policy_number_fallback_to_certificate(self): arbiter = PolicyArbiter() sched = UKMotorGoldenRecord(policy_header=PolicyHeader(policy_number=None)) cert = _make_certificate(policy_number="CERT-999") golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.policy_header.policy_number == "CERT-999" # --------------------------------------------------------------------------- # Conflict detection # --------------------------------------------------------------------------- class TestConflictDetection: def test_no_conflicts_when_values_match(self): arbiter = PolicyArbiter() sched = _make_schedule(policy_number="POL-001", insurer="Insurer A") cert = _make_certificate(policy_number="POL-001") _, conflicts = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") policy_number_conflicts = [c for c in conflicts if c.field == "policy_header.policy_number"] assert policy_number_conflicts == [] def test_conflict_logged_for_differing_policy_numbers(self): arbiter = PolicyArbiter() sched = _make_schedule(policy_number="POL-001") cert = _make_certificate(policy_number="POL-002") golden, conflicts = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") conflict_fields = [c.field for c in conflicts] assert "policy_header.policy_number" in conflict_fields # Schedule wins assert golden.policy_header.policy_number == "POL-001" def test_conflict_entry_has_both_values(self): arbiter = PolicyArbiter() sched = _make_schedule(policy_number="SCHED-100") cert = _make_certificate(policy_number="CERT-200") _, conflicts = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") c = next(x for x in conflicts if x.field == "policy_header.policy_number") assert c.schedule_value == "SCHED-100" assert c.certificate_value == "CERT-200" assert c.winner == "schedule" def test_class_of_use_conflict_certificate_wins(self): arbiter = PolicyArbiter() sched = _make_schedule(class_of_use="Social Only") cert = _make_certificate(class_of_use="Social, Domestic and Pleasure") golden, conflicts = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") c = next((x for x in conflicts if x.field == "cover_and_excesses.class_of_use"), None) assert c is not None assert c.winner == "certificate" assert golden.cover_and_excesses.class_of_use == "Social, Domestic and Pleasure" # --------------------------------------------------------------------------- # Driver merging # --------------------------------------------------------------------------- class TestDriverMerge: def test_exact_name_match_enriches_driver(self): arbiter = PolicyArbiter() sched = _make_schedule( drivers=[{"name": "ALICE SMITH", "is_main_driver": True, "dob": None, "relationship": None}] ) cert = _make_certificate( drivers=[{"name": "ALICE SMITH", "is_main_driver": True, "relationship": "Proposer"}] ) golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.driver_details[0].relationship == "Proposer" def test_fuzzy_name_match_merges(self): """Names with minor differences (e.g. missing middle initial) should still match.""" arbiter = PolicyArbiter() sched = _make_schedule( drivers=[{"name": "ALICE J SMITH", "is_main_driver": True}] ) cert = _make_certificate( drivers=[{"name": "ALICE SMITH", "is_main_driver": True, "relationship": "Proposer"}] ) golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") assert golden.driver_details[0].relationship == "Proposer" def test_unmatched_driver_has_no_cert_enrichment(self): """A driver with a completely different name gets no cert data.""" arbiter = PolicyArbiter() sched = _make_schedule( drivers=[{"name": "ALICE SMITH", "is_main_driver": True}] ) cert = _make_certificate( drivers=[{"name": "BOB JONES", "is_main_driver": True, "relationship": "Spouse"}] ) golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") alice = golden.driver_details[0] assert alice.name == "ALICE SMITH" assert alice.relationship is None # no cert match, so no enrichment # --------------------------------------------------------------------------- # field_citations merging # --------------------------------------------------------------------------- class TestFieldCitationsMerge: def test_schedule_citations_win_on_conflict(self): arbiter = PolicyArbiter() sched = _make_schedule() cert = _make_certificate() sched.field_citations = { "vehicle_details.vrm": "AB12 XYZ", "policy_header.policy_number": "POL-001 from schedule", } cert.field_citations = { "policy_header.policy_number": "POL-001 from cert", "cover_and_excesses.class_of_use": "Social, Domestic and Pleasure", } golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") fc = golden.field_citations or {} # Schedule wins the shared key assert fc.get("policy_header.policy_number") == "POL-001 from schedule" # Cert-only key survives assert fc.get("cover_and_excesses.class_of_use") == "Social, Domestic and Pleasure" # Schedule-only key survives assert fc.get("vehicle_details.vrm") == "AB12 XYZ" def test_empty_citations_produce_none(self): arbiter = PolicyArbiter() sched = _make_schedule() cert = _make_certificate() golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf") # Neither side has citations → merged record has None assert golden.field_citations is None