"""Tests for the proposal store + approval state machine (pure logic, no network).""" import copy import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import proposals # noqa: E402 SAMPLE = {"intent": "new_investor", "investor_name": "Acme Capital", "contact_name": "Jane Doe", "contact_email": "jane@acme.com", "contact_title": None, "note": "met at conf"} def test_store_put_get_pop(): s = proposals.ProposalStore() assert not s.has("$root") s.put("$root", SAMPLE) assert s.has("$root") assert s.get("$root")["investor_name"] == "Acme Capital" assert s.pop("$root")["investor_name"] == "Acme Capital" assert not s.has("$root") assert s.pop("$missing") is None def test_store_any_pending(): s = proposals.ProposalStore() assert not s.any_pending() s.put("$r", SAMPLE) assert s.any_pending() s.pop("$r") assert not s.any_pending() def test_interpret_yes_variants(): for t in ("yes", "Y", "approve", " ok ", "👍"): assert proposals.interpret_reply(t)[0] == "approve", t def test_interpret_no_variants(): for t in ("no", "N", "cancel", "discard", "❌"): assert proposals.interpret_reply(t)[0] == "reject", t def test_interpret_edit_equals(): action, payload = proposals.interpret_reply("edit email=new@acme.com") assert action == "edit" assert payload == ("contact_email", "new@acme.com") def test_interpret_edit_colon_and_alias(): action, payload = proposals.interpret_reply("firm: Acme Capital LLC") assert action == "edit" assert payload == ("investor_name", "Acme Capital LLC") def test_interpret_unknown(): assert proposals.interpret_reply("maybe later")[0] == "unknown" def test_interpret_edit_colon_value_contains_equals(): # the '=' inside the value must not break parsing — split on ':' first, keep the rest action, payload = proposals.interpret_reply("note: see deck=v2") assert action == "edit" assert payload == ("note", "see deck=v2") def test_claim_once_pop_guards_double_approve(): # the double-approve guard relies on pop() yielding the proposal exactly once; # a second claim returns None so a racing second 'yes' is a no-op s = proposals.ProposalStore() s.put("$r", SAMPLE) assert s.pop("$r") is not None assert s.pop("$r") is None def test_edit_with_unknown_field_is_not_an_edit(): # an unknown field name must not silently become an edit assert proposals.interpret_reply("edit zipcode=90210")[0] == "unknown" def test_apply_edit_is_nondestructive(): updated = proposals.apply_edit(SAMPLE, "contact_email", "x@y.com") assert updated["contact_email"] == "x@y.com" assert SAMPLE["contact_email"] == "jane@acme.com" # original untouched def test_render_includes_fields_and_instructions(): text = proposals.render(SAMPLE) assert "Acme Capital" in text assert "jane@acme.com" in text assert "yes" in text.lower() and "no" in text.lower() def test_render_meeting_note_variant(): note = dict(SAMPLE, intent="meeting_note") assert "meeting note" in proposals.render(note).lower() def test_summary_line_new_vs_note(): new_line = proposals.summary_line(SAMPLE) assert "Acme Capital" in new_line and "new investor" in new_line.lower() note_line = proposals.summary_line(dict(SAMPLE, intent="meeting_note")) assert "Acme Capital" in note_line and "meeting note" in note_line.lower() # the nudge must point the user to the thread, where the actual action lives assert "thread" in new_line.lower() # --- fuzzy-match disambiguation + conversational-revision helpers --- DISAMBIG = {"intent": "new_investor", "investor_name": "Charles Brown", "contact_name": "Charles Brown", "contact_email": None, "contact_title": None, "note": "met at conf", "_stage": "disambiguate", "_candidates": [{"id": "rowCharlie", "name": "Charlie Brown", "score": 0.92, "matched_on": "name"}, {"id": "rowBeta", "name": "Beta Capital LLC", "score": 0.7, "matched_on": "name"}]} def test_interpret_disambiguation_pick_number(): assert proposals.interpret_disambiguation("1", 2) == ("pick", 0) assert proposals.interpret_disambiguation(" 2 ", 2) == ("pick", 1) assert proposals.interpret_disambiguation("#1", 2) == ("pick", 0) def test_interpret_disambiguation_out_of_range_is_unknown(): assert proposals.interpret_disambiguation("3", 2)[0] == "unknown" assert proposals.interpret_disambiguation("0", 2)[0] == "unknown" def test_interpret_disambiguation_new_and_no(): assert proposals.interpret_disambiguation("new", 2)[0] == "new" assert proposals.interpret_disambiguation("none of these", 2)[0] == "new" assert proposals.interpret_disambiguation("no", 2)[0] == "reject" def test_interpret_disambiguation_freeform_is_unknown(): # a free-form reply in the shortlist stage isn't guessed at — re-prompt instead assert proposals.interpret_disambiguation("the first one", 2)[0] == "unknown" def test_attach_to_candidate_promotes_to_meeting_note(): out = proposals.attach_to_candidate(DISAMBIG, DISAMBIG["_candidates"][0]) assert out["_match_id"] == "rowCharlie" assert out["intent"] == "meeting_note" assert out["_stage"] == "approval" assert out["investor_name"] == "Charlie Brown" # canonical existing name shown assert "_candidates" not in out assert "_candidates" in DISAMBIG # original untouched def test_promote_to_new_clears_shortlist_and_match(): out = proposals.promote_to_new(dict(DISAMBIG, _match_id="rowX")) assert out["_stage"] == "approval" assert "_candidates" not in out assert "_match_id" not in out def test_disambiguation_pick_then_yes_reaches_approval(): # Closes the seam between the two state machines: a shortlist pick promotes the proposal to # approval stage carrying the chosen investor's row id, and a following 'yes' classifies as # approve (the normal commit path) — so pick -> yes lands the note on the existing investor. picked = proposals.attach_to_candidate(copy.deepcopy(DISAMBIG), DISAMBIG["_candidates"][0]) assert picked["_stage"] == "approval" assert picked["_match_id"] == "rowCharlie" assert picked["intent"] == "meeting_note" assert proposals.interpret_reply("yes") == ("approve", None) def test_render_disambiguation_lists_numbered_candidates(): text = proposals.render_disambiguation(DISAMBIG) assert "Charlie Brown" in text and "Beta Capital LLC" in text assert "1." in text and "2." in text assert "new" in text.lower() and "no" in text.lower() def test_same_fields_ignores_control_keys(): a = dict(SAMPLE) assert proposals.same_fields(a, dict(a)) assert not proposals.same_fields(a, dict(a, note="different")) assert proposals.same_fields(a, dict(a, _match_id="r1", _stage="approval")) if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: fn() print(f"ok {fn.__name__}") print(f"\n{len(fns)} passed")