"""Tests for attribution module.""" import pytest from lib.attribution import ( build_attribution_block, parse_attribution, role_counts_from_attribution, validate_attribution, ) class TestParseAttribution: def test_nested_format(self): fm = { "type": "claim", "attribution": { "extractor": [{"handle": "rio", "agent_id": "760F7FE7"}], "sourcer": [{"handle": "@theiaresearch", "context": "annual letter"}], }, } result = parse_attribution(fm) assert len(result["extractor"]) == 1 assert result["extractor"][0]["handle"] == "rio" assert result["sourcer"][0]["handle"] == "theiaresearch" # @ stripped def test_flat_format(self): fm = { "type": "claim", "attribution_extractor": "rio", "attribution_sourcer": "@theiaresearch", } result = parse_attribution(fm) assert result["extractor"][0]["handle"] == "rio" assert result["sourcer"][0]["handle"] == "theiaresearch" def test_legacy_source_fallback_removed(self): """Legacy `source` heuristic removed (Ganymede review, Apr 24). It fabricated handles from descriptive strings (garbage like 'sec-interpretive-release-s7-2026-09-(march-17'). Claims without explicit attribution now return empty — better to surface as data hygiene than invent contributors. """ fm = { "type": "claim", "source": "@pineanalytics, Q4 2025 report", } result = parse_attribution(fm) assert all(len(v) == 0 for v in result.values()) def test_bad_handles_filtered(self): """Handles with spaces, parens, or garbage chars are dropped.""" fm = { "sourcer": "governance---meritocratic-voting-+-futarchy", } result = parse_attribution(fm) assert len(result["sourcer"]) == 0 def test_valid_handle_with_hyphen_passes(self): """Legitimate handles like 'cameron-s1' survive the filter.""" fm = {"sourcer": "cameron-s1"} result = parse_attribution(fm) assert result["sourcer"][0]["handle"] == "cameron-s1" def test_empty_attribution(self): fm = {"type": "claim"} result = parse_attribution(fm) assert all(len(v) == 0 for v in result.values()) def test_string_entries(self): fm = { "attribution": { "extractor": ["rio"], "sourcer": "theiaresearch", }, } result = parse_attribution(fm) assert result["extractor"][0]["handle"] == "rio" assert result["sourcer"][0]["handle"] == "theiaresearch" class TestValidateAttribution: def test_valid_attribution(self): fm = { "attribution": { "extractor": [{"handle": "rio"}], }, } issues = validate_attribution(fm) assert len(issues) == 0 def test_missing_extractor(self): fm = {"attribution": {"sourcer": [{"handle": "someone"}]}} issues = validate_attribution(fm) assert "missing_attribution_extractor" in issues def test_no_attribution_block_passes(self): """Legacy claims without attribution block should NOT be blocked.""" fm = {"type": "claim", "source": "some source"} issues = validate_attribution(fm) assert len(issues) == 0 # No attribution block = legacy, not an error def test_attribution_block_missing_extractor(self): """Claims WITH attribution block but missing extractor SHOULD be blocked.""" fm = {"type": "claim", "attribution": {"sourcer": [{"handle": "someone"}]}} issues = validate_attribution(fm) assert "missing_attribution_extractor" in issues def test_missing_extractor_auto_fix_with_agent(self): """When agent is provided, auto-fix missing extractor instead of blocking.""" fm = {"attribution": {"sourcer": [{"handle": "someone"}]}} issues = validate_attribution(fm, agent="leo") assert "fixed_missing_extractor" in issues assert "missing_attribution_extractor" not in issues # Verify the fix was applied in-place assert fm["attribution"]["extractor"] == [{"handle": "leo"}] def test_missing_extractor_no_agent_still_blocks(self): """Without agent context, missing extractor is still a hard failure.""" fm = {"attribution": {"sourcer": [{"handle": "someone"}]}} issues = validate_attribution(fm, agent=None) assert "missing_attribution_extractor" in issues class TestBuildAttributionBlock: def test_basic_build(self): attr = build_attribution_block("rio", agent_id="760F7FE7") assert attr["extractor"][0]["handle"] == "rio" assert attr["extractor"][0]["agent_id"] == "760F7FE7" def test_with_sourcer(self): attr = build_attribution_block("rio", source_handle="@PineAnalytics", source_context="Q4 report") assert attr["sourcer"][0]["handle"] == "pineanalytics" assert attr["sourcer"][0]["context"] == "Q4 report" def test_empty_roles(self): attr = build_attribution_block("rio") assert attr["challenger"] == [] assert attr["synthesizer"] == [] assert attr["reviewer"] == [] class TestRoleCounts: def test_basic_counts(self): attribution = { "extractor": [{"handle": "rio"}], "sourcer": [{"handle": "theia"}, {"handle": "pine"}], "challenger": [], "synthesizer": [], "reviewer": [{"handle": "leo"}], } counts = role_counts_from_attribution(attribution) assert counts["extractor"] == ["rio"] assert counts["sourcer"] == ["theia", "pine"] assert "challenger" not in counts assert counts["reviewer"] == ["leo"]