"""Tests for the dashboard ranking / float-to-top logic (spec §5, DESIGN §4). Pure-logic coverage of the heart of §5, no DB: * a WATCHED-LEGISLATOR match raises a bill's score so it floats to the top; * a TAG match (opted-in topic) raises a bill's score so it floats to the top; * ANON view has NO relevance and NO personalized sort (personal=off excludes it); * hearing_sort orders "Upon Call of the Chair" last, times asc; * a T-1 day is excluded from an upcoming view; * "Why is this here?" (matched[]) transparency carries the trigger. """ from __future__ import annotations from scraper.dashboard import ranking from scraper.dashboard.ranking import ( hearing_sort, score_bill, normalise_bill, float_to_top, personal_band, full_queue, is_floated, Weights, ) from scraper.dashboard.context import RelevanceContext, from_prefs def _bill(bill_id, author, committee=None, tags=None, chamber="ASSEMBLY", hearing_time="9:30am", date="2026-09-07", short_title="t", description="d"): b = { "bill_id": bill_id, "bill_number": bill_id, "chamber_origin": chamber, "author": author, "short_title": short_title, "description": description, "committee": committee, "hearing_time": hearing_time, "location": "Room 1", "date": date, "session": "2025-2026", "item_number": 1, "supplement": 1, "source": "ASSEMBLY_DAILY_FILE", "tags": tags, } b["hearing_sort"] = hearing_sort(hearing_time) return b def _bills(): # Three bills, all on the same day. B2 is authored by a watched legislator; # B3 is in a topic (education) the user opted into; B1 matches nothing. return [ _bill("A1", "Unrelated McPerson", committee="General", tags=["budget"], date="2026-09-07"), _bill("A2", "Schiavo et al.", committee="Resources and Water", date="2026-09-07"), # author watched path _bill("A3", "Macedo", committee=None, tags=["education", "health"], date="2026-09-07"), # tag match path ] # --------------------------------------------------------------------------- # hearing_sort # --------------------------------------------------------------------------- def test_hearing_sort_walltime_ascending(): assert hearing_sort("9:30am") == 9 * 3600 + 30 * 60 assert hearing_sort("10:00am") > hearing_sort("9:30am") assert hearing_sort("1:00pm") > hearing_sort("12:00pm") # 1pm = 13:00 assert hearing_sort("12:00am") == 0 # midnight def test_hearing_sort_call_of_chair_and_null_sort_last(): last = hearing_sort("Upon Call of the Chair") assert last == hearing_sort("Call of the Chair") assert last == hearing_sort(None) # missing time sinks (time TBD) assert last == hearing_sort("") # a real wall time sorts before call-of-the-chair / nulls. assert hearing_sort("9:30am") < last # --------------------------------------------------------------------------- # score_bill — the OR rule (author OR tag suffices to float) # --------------------------------------------------------------------------- def test_watched_author_floats(): b = _bills()[1] # authored by "Schiavo et al." rel = score_bill(b, watched_authors={"schiavo"}, watched_committees=set(), opted_in_tags=set()) assert rel["score"] >= 3, rel assert is_floated({**b, "relevance": rel}) # transparency: matched[] names the rule (a "Why is this here?" trigger) types = [m["type"] for m in rel["matched"]] assert "watched-legislator" in types def test_opted_in_tag_floats(): # A bill that is NOT authored by a watched leg but carries an opted-in # topic floats on the TAG path alone. b = _bills()[2] # tags ["education","health"] rel = score_bill(b, watched_authors=set(), watched_committees=set(), opted_in_tags={"education"}) assert rel["score"] > 0, rel assert is_floated({**b, "relevance": rel}) types = [m["type"] for m in rel["matched"]] assert "tag" in types # matched carries the actual tag that triggered it assert any(m["value"].lower().startswith("topic") for m in rel["matched"]) def test_no_match_does_not_float(): # A bill matching nothing scores 0 and does NOT float. b = _bills()[0] rel = score_bill(b, watched_authors=set(), watched_committees=set(), opted_in_tags=set()) assert rel["score"] == 0 assert not is_floated({**b, "relevance": rel}) def test_both_paths_stack_score(): # Author watched + an opted-in tag both present -> score > either alone. b = _bill("A2", "Schiavo et al.", tags=["education"]) rel = score_bill(b, watched_authors={"schiavo"}, watched_committees=set(), opted_in_tags={"education"}) alone = score_bill(b, watched_authors={"schiavo"}, watched_committees=set(), opted_in_tags=set()) assert rel["score"] > alone["score"] assert {"watched-legislator", "tag"}.issubset( {m["type"] for m in rel["matched"]}) def test_committee_of_watched_legislator_path(): # A bill in a committee a watched legislator chairs/serves on floats even # with no author/tag match (committee->members join supplied). b = _bill("A4", "Otherperson", committee="Emergency Management", tags=[], ) rel = score_bill(b, watched_authors=set(), watched_committees={"emergency-management"}, opted_in_tags=set()) assert rel["score"] > 0, rel assert "committee-of-watched-legislator" in [ m["type"] for m in rel["matched"]] # --------------------------------------------------------------------------- # float-to-top order + anon exclusion of personalized sort # --------------------------------------------------------------------------- def _personal_bills(): bills = _bills() ctx = from_prefs({"watched_legislators": ["schiavo"], "opted_in_tags": ["education"]}) for b in bills: b["relevance"] = ctx.score(b) return bills, ctx def test_float_to_top_puts_relevant_first(): bills, ctx = _personal_bills() assert ctx.is_personalised ordered = float_to_top(bills) # A2 (author) and A3 (tag) float ahead of A1 (no match). ids = [b["bill_id"] for b in ordered] assert ids.index("A2") < ids.index("A1") assert ids.index("A3") < ids.index("A1") # floated band = top N of the floated bills band = personal_band(bills) assert {b["bill_id"] for b in band} == {"A2", "A3"} assert all(is_floated(b) for b in band) def test_personal_band_bounded_to_n(): bills, ctx = _personal_bills() band = personal_band(bills, n=1) assert len(band) == 1 # higher score first (A2 = author+nothing vs A3 = tag+nothing both score # 3 and 2 respectively; A2 should lead when scores differ) a2 = next(b for b in bills if b["bill_id"] == "A2") a3 = next(b for b in bills if b["bill_id"] == "A3") assert a2["relevance"]["score"] >= a3["relevance"]["score"] assert band[0]["bill_id"] == "A2" def test_full_queue_promotes_but_hides_nothing(): # Every bill remains present; only order changes (promote, not hide). bills, ctx = _personal_bills() ordered = full_queue(bills) assert {b["bill_id"] for b in ordered} == {"A1", "A2", "A3"} assert len(ordered) == 3 def test_anon_view_has_no_relevance_and_no_personal_sort(): # The anon envelope carries NO relevance block and is NOT float-sorted: # the personal=off path must not reorder anything. bills = _bills() anon = [dict(b, **{"relevance": None}) for b in bills] # strip relevance to mirror the anon projection anon = [{k: v for k, v in b.items() if k != "relevance"} for b in _bills()] assert all("relevance" not in b for b in anon) # anon ordering == stable input order (no float applied) ordered = float_to_top([ {k: v for k, v in b.items() if k != "relevance"} for b in _bills()]) assert [b["bill_id"] for b in ordered] == ["A1", "A2", "A3"] def test_weights_are_tunable(): # D3: weights are knobs. Bumping the tag weight changes band ordering. bills, ctx = _personal_bills() hi = Weights(tag_watched=10) for b in bills: b["relevance"] = score_bill(b, watched_authors=ctx.watched_authors, watched_committees=ctx.watched_committees, opted_in_tags=ctx.opted_in_tags, weights=hi) ordered = float_to_top(bills) # A3 (tag, weight 10) now outranks A2 (author, weight 3) assert ordered[0]["bill_id"] == "A3"