e824ff2206
Completes business-card contact capture. The transcription prompt now labels Phone/Mobile/Fax on separate lines, and the extractor maps an office/main number -> phone and a cell -> mobile, never a fax. Both carry the same digit-in-source integrity rule as email/LinkedIn: a number is kept only if its digits literally appear in the source (or, on revise, the instruction) -- never minted. The proposal card shows Phone + Mobile and they're editable (aliases phone/tel/office, mobile/cell). Server: _upsert_contact_from_fundraising now accepts contact.phone + contact.mobile and writes them to the canonical contact record (contact-level, not grid pills), shipped in s9pk v0.1.0:98. No schema change -- the contacts columns already exist. 41/41 backend suite green + the matrix_intake units; card flow end-to-end is live-smoke.
214 lines
8.0 KiB
Python
214 lines
8.0 KiB
Python
"""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_edit_city_and_linkedin_aliases():
|
|
a1, p1 = proposals.interpret_reply("city: New York")
|
|
assert (a1, p1) == ("edit", ("city", "New York"))
|
|
a2, p2 = proposals.interpret_reply("linkedin=linkedin.com/in/jane")
|
|
assert (a2, p2) == ("edit", ("linkedin_url", "linkedin.com/in/jane"))
|
|
|
|
|
|
def test_render_shows_city_and_linkedin_when_present():
|
|
p = {**SAMPLE, "city": "New York", "linkedin_url": "linkedin.com/in/jane"}
|
|
out = proposals.render(p)
|
|
assert "City: New York" in out
|
|
assert "LinkedIn: linkedin.com/in/jane" in out
|
|
|
|
|
|
def test_interpret_edit_phone_and_mobile_aliases():
|
|
assert proposals.interpret_reply("phone=212-555-0100") == ("edit", ("phone", "212-555-0100"))
|
|
assert proposals.interpret_reply("cell: 917-555-0199") == ("edit", ("mobile", "917-555-0199"))
|
|
|
|
|
|
def test_render_shows_phone_and_mobile_when_present():
|
|
p = {**SAMPLE, "phone": "212-555-0100", "mobile": "917-555-0199"}
|
|
out = proposals.render(p)
|
|
assert "Phone: 212-555-0100" in out
|
|
assert "Mobile: 917-555-0199" in out
|
|
|
|
|
|
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")
|