diff --git a/CLAUDE.md b/CLAUDE.md index f222524..e0efcec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Dependencies run strictly downward: `llm`/`netutil`/`sources` ← `smstext`/`wea Underscore prefixes still mean "internal to Palmer", not "private to this module" — `smstext._sms_clean` is imported by six modules. Grep before renaming. -**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target does not fail loudly — it lets the test make real API calls. Watch the suite runtime: ~1,400 tests in ~6s, and a jump means something is hitting the network. +**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target used to fail silently by letting the test make a real API call; `tests/conftest.py` now refuses any socket connection and any unpatched Anthropic call, so most escapes fail by name instead — except through `netutil._http_get_json` (flights, hotels, serpapi, opening, traffic, tickers), which catches every exception by contract and returns `None`, so an escape there still reads as an empty result rather than a named failure. The suite is ~1,430 tests in ~5s, in 15 files grouped by the module under test, with shared fakes in `tests/helpers.py`. ### Scheduler cadence (main.py) ``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8103c9a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared fixtures. Each one exists to delete a block that was copy-pasted +across the suite. + +The env placeholders are set before any palmer module is imported: a few +modules read their key at import time (datafeeds raises KeyError without +TAVILY_API_KEY), and PALMER_NO_SCHEDULER keeps `from palmer import main` from +starting the job loop. Nothing in the suite needs a real value, and `.env` is +deliberately not loaded — real keys reaching a test is how the suite used to +go to the network by accident. +""" +import os +import socket + +import pytest + +for _k, _v in { + "ANTHROPIC_API_KEY": "test", + "TWILIO_ACCOUNT_SID": "ACtest", + "TWILIO_AUTH_TOKEN": "test", + "TWILIO_PHONE_NUMBER": "+15550000000", + "TAVILY_API_KEY": "test", + "TOMTOM_API_KEY": "test", + "SERP_API_KEY": "test", + "PALMER_NO_SCHEDULER": "1", +}.items(): + os.environ.setdefault(_k, _v) + +from palmer import db # after the env block, on purpose + + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + """Every test is offline. A fetch that escapes its patch used to succeed + silently on a machine with live keys and show up only as a slower suite; + now it fails, naming itself. Socket level, because fetch functions are + re-exported and patched under many module names.""" + def _refuse(*_a, **_k): + raise RuntimeError("network call inside the test suite — patch the fetch") + monkeypatch.setattr(socket.socket, "connect", _refuse) + monkeypatch.setattr(socket, "create_connection", _refuse) + monkeypatch.setattr(socket, "getaddrinfo", _refuse) + + +@pytest.fixture(autouse=True) +def _no_llm(monkeypatch): + """An Anthropic call nobody patched fails at once instead of reaching the + network. Found the day the socket guard landed: a dozen tests were + calling the real API with a placeholder key on every run and taking + 1.4s each in the SDK's retry backoff, then swallowing the error. Class + level, so `patch.object(x.client.messages, "create")` in a test still + wins — an instance attribute shadows this.""" + from palmer.llm import client + def _refuse(*_a, **_k): + raise RuntimeError("unpatched Anthropic call inside the test suite") + monkeypatch.setattr(type(client.messages), "create", _refuse) + + +@pytest.fixture +def fresh_db(tmp_path, monkeypatch): + """An empty schema in a temp file, in place of the repo-root palmer.db.""" + assert not db._DATABASE_URL, ( + "DATABASE_URL is set — fresh_db only isolates the SQLite path, so " + "these tests would run against that real database. Unset it first." + ) + monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test.db") + db.init_db() + return db diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..3e761c9 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,64 @@ +"""Fakes shared across test files. Import what you need: + + from tests.helpers import llm_reply, Block, Resp, tool_by_name, drive_tool +""" +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +from palmer.tools_def import TOOLS + + +def llm_reply(text: str) -> MagicMock: + """A messages.create response whose one content block carries `text`.""" + block = MagicMock() + block.text = text + resp = MagicMock() + resp.content = [block] + return resp + + +class Block: + """A content block as the Anthropic SDK shapes it: attribute access only.""" + def __init__(self, **kw): + self.__dict__.update(kw) + + +class Resp: + """A messages.create response with real `content` and `stop_reason`.""" + def __init__(self, content, stop_reason): + self.content, self.stop_reason = content, stop_reason + + +def tool_by_name(name: str) -> dict | None: + return next((t for t in TOOLS if t["name"] == name), None) + + +def drive_tool(tool_name, tool_input, *, message="hi", reply="done", profile=None, + history=(), patches=()): + """Run agent.get_reply through exactly one tool call. + + The model is faked to call `tool_name` with `tool_input`, then answer + `reply`. Returns (text, tool_result, mocks): the reply text, the tool_result + string the model was handed back, and the entered values of `patches` in + order — pass `patch.object(...)` objects and read the mocks back here.""" + from palmer import agent + calls = [] + responses = [ + Resp([Block(type="tool_use", name=tool_name, id="t1", input=tool_input)], "tool_use"), + Resp([Block(type="text", text=reply)], "end_turn"), + ] + + def _create(**kw): + calls.append(kw) + return responses[len(calls) - 1] + + with ExitStack() as stack: + stack.enter_context(patch.object(agent, "_build_system", return_value="sys")) + stack.enter_context(patch.object(agent, "get_history", return_value=list(history))) + stack.enter_context(patch.object(agent, "get_profile", + return_value=profile if profile is not None else {})) + mocks = [stack.enter_context(p) for p in patches] + stack.enter_context(patch.object(agent.client.messages, "create", side_effect=_create)) + text, _gif = agent.get_reply("+1555", message) + result = calls[1]["messages"][-1]["content"][0]["content"] + return text, result, mocks diff --git a/tests/test_add_price_watch_tool.py b/tests/test_add_price_watch_tool.py deleted file mode 100644 index 59e9f24..0000000 --- a/tests/test_add_price_watch_tool.py +++ /dev/null @@ -1,82 +0,0 @@ -"""add_price_watch's dispatch used to insert a row with baseline_price=NULL and -defer baseline-setting to the next scheduler tick (12h later), unlike -add_amazon_watch which seeds it immediately. If that first scheduler-side -match ever failed, the baseline stayed NULL forever and run_price_watches -could never reach the alert comparison — a real drop would just get silently -recorded as the (late) baseline with no alert. Seed it at creation time, -same as Amazon, so a bad match is visible immediately instead of silent.""" -from unittest.mock import patch - -from palmer import agent - - -class _Block: - def __init__(self, **kw): - self.__dict__.update(kw) - - -class _Resp: - def __init__(self, content, stop_reason): - self.content, self.stop_reason = content, stop_reason - - -def _drive(tool_input, check_price_result): - calls = [] - responses = [ - _Resp([_Block(type="tool_use", name="add_price_watch", id="t1", input=tool_input)], "tool_use"), - _Resp([_Block(type="text", text="done")], "end_turn"), - ] - - def _create(**kw): - calls.append(kw) - return responses[len(calls) - 1] - - with patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch.object(agent, "save_price_watch", return_value=42) as save, \ - patch.object(agent, "set_price_watch_baseline") as set_baseline, \ - patch("palmer.shopping.check_price", return_value=check_price_result), \ - patch.object(agent.client.messages, "create", side_effect=_create): - agent.get_reply("+1555", "track this for me") - result = calls[1]["messages"][-1]["content"][0]["content"] - return result, save, set_baseline - - -class TestBaselineSeededAtCreation: - def test_a_successful_match_seeds_the_baseline_immediately(self): - current = {"price": 29.99, "url": "https://example.com/p", "merchant": "Target"} - result, save, set_baseline = _drive({"product_name": "Premier Protein Chocolate 30-pack"}, current) - save.assert_called_once() - set_baseline.assert_called_once_with(42, 29.99, "https://example.com/p", "Target") - assert "29.99" in result - - def test_a_failed_match_does_not_seed_a_baseline_but_still_creates_the_watch(self): - result, save, set_baseline = _drive({"product_name": "some obscure item"}, None) - save.assert_called_once() - set_baseline.assert_not_called() - assert "couldn't pin down a confident match" in result.lower() - - -class TestTheWatchNamesWhatItMatched: - """add_amazon_watch echoes the resolved listing; this path echoed the - user's own words back. The match is picked by a model with no confidence - floor, so "AirPods" can baseline on Gen 2, Gen 4 or Pro — and a wrong pick - stayed invisible until an alert arrived about the wrong product.""" - - def test_the_matched_title_is_in_the_tool_result(self): - import inspect - from palmer import agent - block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ - .split("elif b.name")[0] - assert 'current.get("title")' in block - assert "It matched:" in block - - def test_the_model_is_told_to_say_it_out_loud(self): - """A resolved thing named only in the tool result is still invisible - to the person who can correct it.""" - import inspect - from palmer import agent - block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ - .split("elif b.name")[0] - assert "correct you" in block diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..2d17944 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,941 @@ +"""The conversation loop: prompt calibration, resilience, tool dispatch. + +Merged from test_calibration.py, test_reply_resilience.py, test_commute.py, test_add_price_watch_tool.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import inspect +import re +from unittest.mock import patch, MagicMock +from palmer import agent, prompts, reminders, artifacts, home, morning, page, traffic, userprofile +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from palmer.timeutil import friendly_hhmm +from tests.helpers import drive_tool, tool_by_name + + +# ============================================================================ +# from test_calibration.py +# ============================================================================ +# +# Tests for register calibration — Palmer adapting HOW he sounds to who's texting. +# +# The personality lives in prose inside SYSTEM_PROMPT, so these are structural +# assertions in the style of test_rubrics.py: the sections exist, they say the +# load-bearing thing, and the profile signal actually reaches the prompt. +# +# The ordering test is not cosmetic. SOUND CHECK is nine examples in one cultural +# register, and few-shot beats instructions — SAME PALMER, DIFFERENT PEOPLE has to +# come after it to be the last word on voice. + +def _render() -> str: + """SYSTEM_PROMPT as _build_system renders it.""" + return agent.SYSTEM_PROMPT.format(clock_block="RIGHT NOW\n(clock)", + profile_block="(none)") + + +class TestCalibrationSection: + def test_section_exists(self): + assert "CALIBRATION\n" in agent.SYSTEM_PROMPT + + def test_names_all_four_axes(self): + body = agent.SYSTEM_PROMPT + for axis in ("Irony tolerance", "Precision", "Formality and idiom", "Directness"): + assert axis in body, f"calibration axis {axis!r} missing" + + def test_spine_is_non_negotiable(self): + """Calibrating must never be licence to become a neutral assistant.""" + body = agent.SYSTEM_PROMPT.lower() + assert "the spine doesn't" in body + assert "neutral assistant" in body + + def test_does_not_announce_the_adjustment(self): + assert "never announce that you're adjusting" in agent.SYSTEM_PROMPT.lower() + + +class TestRangeExamples: + def test_block_exists(self): + assert "SAME PALMER, DIFFERENT PEOPLE" in agent.SYSTEM_PROMPT + + def test_comes_after_sound_check(self): + body = agent.SYSTEM_PROMPT + assert body.index("SOUND CHECK") < body.index("SAME PALMER, DIFFERENT PEOPLE"), \ + "range examples must follow SOUND CHECK or the single-register block wins" + + def test_sound_check_is_labelled_as_one_register(self): + """The original nine examples must not read as the only way to sound.""" + head = agent.SYSTEM_PROMPT.split("SOUND CHECK\n", 1)[1].split("them:", 1)[0] + assert head.strip(), "SOUND CHECK has no lead-in labelling it as one register" + assert "one register" in head.lower() + + def test_covers_distinct_registers(self): + block = agent.SYSTEM_PROMPT.split("SAME PALMER, DIFFERENT PEOPLE", 1)[1] \ + .split("NEW USERS", 1)[0] + assert "Poisson" in block, "no precise/technical register example" + assert "second language" in block, "no formal/ESL register example" + assert "rough day" in block, "no bad-day register example" + + +class TestPromptStillRenders: + def test_format_does_not_raise(self): + """SYSTEM_PROMPT is .format()ed — a stray brace in new prose breaks every reply.""" + _render() + + def test_placeholders_survive(self): + out = _render() + assert "RIGHT NOW\n(clock)" in out + assert "(none)" in out + + +class TestBuildSystemWiring: + def _build(self, profile: dict) -> str: + with patch.object(agent, "get_profile", return_value=profile), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + return agent._build_system("+15550001111") + + def test_communication_style_reaches_the_prompt_as_a_directive(self): + out = self._build({"name": "Ada", + "communication_style": "precise, wants the answer first"}) + assert "CALIBRATION READ" in out + assert "precise, wants the answer first" in out + assert "mirror it" in out + + def test_explicit_request_outranks_inference(self): + out = self._build({"communication_style": "asked directly for less sarcasm"}) + assert "outranks" in out + + def test_absent_style_adds_no_calibration_read(self): + assert "CALIBRATION READ" not in self._build({"name": "Ada"}) + + def test_blank_style_adds_no_calibration_read(self): + assert "CALIBRATION READ" not in self._build({"communication_style": " "}) + + def test_empty_profile_still_builds(self): + assert "CALIBRATION READ" not in self._build({}) + + +class TestOnboardingAsk: + """Message 1 never demands name/city (NEW USERS rule, above). From message + 2 on — intro already sent — if Palmer still doesn't know one or both, the + dynamically-appended ONBOARDING ASK block tells him to work it in, once.""" + + def _build(self, profile: dict, is_new_user: bool = False) -> str: + with patch.object(agent, "get_profile", return_value=profile), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + return agent._build_system("+15550001111", is_new_user=is_new_user) + + def test_asks_for_both_when_both_are_missing(self): + out = self._build({"intro_sent": True}) + assert "ONBOARDING ASK" in out + assert "name and what city" in out + + def test_asks_only_for_the_one_still_missing(self): + out = self._build({"intro_sent": True, "name": "Ada"}) + assert "ONBOARDING ASK" in out + assert "their city" in out + assert "name and what city" not in out + + def test_silent_on_the_very_first_message(self): + """Message 1 is is_new_user=True — the NEW USERS rules own that reply, + not this block, even if the profile happens to be empty.""" + assert "ONBOARDING ASK" not in self._build({}, is_new_user=True) + + def test_silent_once_name_and_city_are_both_known(self): + out = self._build({"intro_sent": True, "name": "Ada", "city": "Chicago"}) + assert "ONBOARDING ASK" not in out + + def test_silent_once_already_asked(self): + """Consumed once by userprofile._update_profile — see test_profile_schema.py.""" + out = self._build({"intro_sent": True, "onboarding_ask_sent": True}) + assert "ONBOARDING ASK" not in out + + def test_never_volunteers_the_link(self): + out = self._build({"intro_sent": True}) + block = out.split("ONBOARDING ASK", 1)[1].lower() + assert "send any link" in block or "don't mention their page" in block + + +class TestExtractionCapturesRegister: + def test_prompt_asks_for_explicit_requests(self): + body = prompts.EXTRACT_PROMPT.lower() + assert "communication_style" in body + assert "verbatim" in body, "explicit register asks must be recorded as stated" + assert "joke back" in body + + +class TestReminderPathHonoursStyle: + """Reminders used to carry their own mini-persona and never saw SYSTEM_PROMPT. + They now go through _build_system like every other user-facing message.""" + + def test_reminder_uses_the_shared_system_prompt(self): + captured = {} + + def _fake_create(**kwargs): + captured.update(kwargs) + block = MagicMock() + block.text = "hey - dentist at 3" + resp = MagicMock() + resp.content = [block] + return resp + + with patch.object(reminders, "_build_system", + return_value="SYSTEM WITH CALIBRATION READ: blunt") as bs, \ + patch.object(reminders.client.messages, "create", side_effect=_fake_create): + out = reminders._personalize_reminder( + "+15550001111", "dentist at 3", + {"communication_style": "blunt, asked for just the facts"}, + ) + + assert out + bs.assert_called_once() + assert captured["system"] == "SYSTEM WITH CALIBRATION READ: blunt" + assert captured["model"] == reminders.SONNET_MODEL, \ + "user-facing drafting belongs on Sonnet" + + def test_reminder_survives_a_drafting_failure(self): + with patch.object(reminders, "_build_system", return_value="SYS"), \ + patch.object(reminders.client.messages, "create", + side_effect=RuntimeError("boom")): + out = reminders._personalize_reminder("+15550001111", "dentist at 3", {}) + assert "dentist at 3" in out, "a failed draft must still deliver the reminder" + + +class TestThePromptDescribesTheProductThatExists: + """SYSTEM_PROMPT is what Palmer paraphrases to a user when he confirms + something. Every claim in it is a promise, and three had gone stale. + + The price rule is the sharpest case: the prompt still said "~15% drops" + long after that bar was deleted for the second time and replaced with a + flat $2 in either direction. So Palmer described a drop-only percentage + watch and then sent a $2 rise alert. Nothing cross-checked the prose + against the constant, which is why it could drift at all. + """ + + def test_the_price_bar_is_the_one_the_code_uses(self): + from palmer import shopping + block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] + assert f"${shopping.MOVE_MIN_ABS:.0f}" in block + + def test_the_prompt_never_promises_a_percentage_bar(self): + """A percentage always encodes an assumption about the kind of product, + and the watch list holds every kind. Two versions failed that way.""" + block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] + assert "%" not in block + + def test_the_price_watch_promise_includes_rises(self): + block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] + assert "rise" in block.lower() + + def test_the_morning_is_described_as_basics_plus_a_link(self): + """It stopped carrying tracked topics two versions ago; the prompt + still promised "sports scores, news, Bitcoin price".""" + block = agent.SYSTEM_PROMPT.split("MORNING BRIEFING")[1].split("PRICE WATCHES")[0] + assert "page" in block + assert "commute" in block.lower() + + def test_nothing_claims_flight_watching_is_missing(self): + """add_flight_watch shipped, and one NEVER bullet still held up "I + can't watch them for changes yet" as the model sentence to imitate — + against the routing block's own "Never say you can't track flights".""" + assert "can't watch them for changes" not in agent.SYSTEM_PROMPT + + +class TestEveryToolIsRouted: + def test_the_prompt_names_every_tool_it_ships_with(self): + """A tool the routing block never mentions is one the model resolves + from its own description alone — and add_watch's description tells it + to fire on "a team, a story, a market", which is exactly what the + block assigns to update_morning_briefing and follow_team.""" + from palmer.tools_def import TOOLS + missing = [t["name"] for t in TOOLS if t["name"] not in agent.SYSTEM_PROMPT] + assert missing == [], f"tools with no routing guidance: {missing}" + + def test_the_three_way_track_collision_is_resolved(self): + block = agent.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] + line = next(ln for ln in block.split("\n") if ln.startswith("- add_watch vs")) + for other in ("update_morning_briefing", "follow_team"): + assert other in line + + def test_cancelling_is_routed_too(self): + """"Stop tracking the Eagles" matches four tools, and guessing deletes + something they wanted.""" + block = agent.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] + for verb in ("cancel_watch", "cancel_reminders", "unfollow_team", + "unfollow_show", "cancel_price_watch", "cancel_flight_watch"): + assert verb in block + + +class TestFailureHasAPolicyOfItsOwn: + """It used to be a subordinate clause inside the anti-competitor NEVER + bullet — the prompt said at length what Palmer must not say on a failure + and almost nothing about what he should.""" + + def test_there_is_a_section_for_it(self): + assert "WHEN A TOOL COMES BACK EMPTY OR BROKEN" in agent.SYSTEM_PROMPT + + def test_it_separates_empty_from_broken_from_incapable(self): + block = agent.SYSTEM_PROMPT.split("WHEN A TOOL COMES BACK EMPTY OR BROKEN")[1] \ + .split("CURATION")[0] + assert "Empty is not broken" in block + assert "invent" in block + assert "somewhere else" in block + + def test_a_failed_lookup_is_not_a_fact_about_the_world(self): + """The failed SpaceX lookup confirmed the model's own stale prior and + Palmer told the user the company was private while SPCX was trading.""" + block = agent.SYSTEM_PROMPT.split("WHEN A TOOL COMES BACK EMPTY OR BROKEN")[1] \ + .split("CURATION")[0] + assert "not a company that isn't listed" in block + + +class TestClarificationOutranksTheRhythmRules: + """The rule permitting a clarifying question was stated once. The pressure + against ending on a question was stated four times, three of them + absolute — including one that is shape-based and unconditional, and so + fires exactly when a clarification needs a second turn.""" + + def test_the_precedence_is_stated(self): + block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ + .split("READ THE SUBTEXT")[0] + assert "outranks" in block + + def test_asking_twice_is_allowed_when_it_is_still_unclear(self): + block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ + .split("READ THE SUBTEXT")[0] + assert "ask again" in block + + def test_a_resolved_thing_is_named_back_not_confirmed_silently(self): + block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ + .split("READ THE SUBTEXT")[0] + assert "resolved" in block + assert "correct it" in block + + +# ============================================================================ +# from test_reply_resilience.py +# ============================================================================ +# +# A turn that needs one tool call too many must still answer. +# +# get_reply capped the tool loop at six and RAISED past it. main.py catches that, +# leaves `reply` as None, and answers a falsy reply with FALLBACK_SMS — so a turn +# like "add Apple, Nvidia and Tesla, then what's my commute" died outright, threw +# away every tool result it had already gathered, and told the user something went +# sideways. Same shape as the deliberation guard: machinery meant to protect the +# user producing the thing the user complained about. + +def _blocks(*, text=None, tool=None): + out = [] + if tool: + b = MagicMock() + b.type = "tool_use" + b.name = tool + b.id = "tu_1" + b.input = {"query": "x"} + del b.text # so `hasattr(b, "text")` is False + out.append(b) + if text is not None: + t = MagicMock() + t.type = "text" + t.text = text + out.append(t) + return out + + +class TestTrimToSentence: + def test_a_truncated_draft_is_cut_back_to_a_boundary(self): + out = agent._trim_to_sentence("Sure. 90 today, commute is 22 min. Anything el") + assert out == "Sure. 90 today, commute is 22 min." + + def test_a_complete_draft_is_untouched(self): + assert agent._trim_to_sentence("90 today.") == "90 today." + + def test_no_boundary_at_all_keeps_the_fragment(self): + """A short fragment still beats nothing — there is no second draft.""" + assert agent._trim_to_sentence("no boundary here") == "no boundary here" + + def test_it_never_returns_something_uselessly_short(self): + # Trimming "Ok. " back to "Ok." would be worse + # than shipping the fragment. + out = agent._trim_to_sentence("Ok. and then the thing about the fare wa") + assert out.startswith("Ok. and then") + + def test_empty_is_safe(self): + assert agent._trim_to_sentence("") == "" + + +class TestMaxTokensDoesNotShipHalfASentence: + def test_the_reply_is_trimmed(self): + resp = MagicMock(stop_reason="max_tokens", + content=_blocks(text="Sure. 90 today. Anything el")) + with patch.object(agent.client.messages, "create", return_value=resp), \ + patch.object(agent, "_build_system", return_value="sys"), \ + patch.object(agent, "get_profile", return_value={}), \ + patch.object(agent, "get_history", return_value=[]): + out, _ = agent.get_reply("+15550001111", "hi", history=[]) + assert out == "Sure. 90 today." + + +class TestTheLoopAnswersInsteadOfDying: + def _run(self, final_text): + """Every response asks for another tool, so the cap is always reached.""" + calls = [] + + def _create(**kw): + calls.append(kw) + if "tools" not in kw: # the final, tool-less ask + return MagicMock(stop_reason="end_turn", + content=_blocks(text=final_text)) + return MagicMock(stop_reason="tool_use", content=_blocks(tool="web_search")) + + with patch.object(agent.client.messages, "create", side_effect=_create), \ + patch.object(agent, "_build_system", return_value="sys"), \ + patch.object(agent, "get_profile", return_value={}), \ + patch.object(agent, "get_history", return_value=[]), \ + patch.object(agent, "_search", return_value="some results"): + out, _ = agent.get_reply("+15550001111", "do lots", history=[]) + return out, calls + + def test_it_returns_an_answer_rather_than_raising(self): + out, _ = self._run("here's what I found, in short") + assert out == "here's what I found, in short" + + def test_the_final_ask_has_no_tools(self): + """Tools are taken away so the model must answer from what it gathered.""" + _, calls = self._run("answer") + assert "tools" not in calls[-1] + + def test_it_stops_at_the_cap(self): + _, calls = self._run("answer") + # One call per iteration, plus the final tool-less one. + assert len(calls) == agent.TOOL_ITERATION_CAP + 1 + + def test_the_gathered_work_is_carried_into_the_final_ask(self): + _, calls = self._run("answer") + assert len(calls[-1]["messages"]) > 1, "tool results must survive" + + def test_the_cap_is_high_enough_for_ordinary_asks(self): + """Three tickers plus a commute is five calls before Palmer speaks.""" + assert agent.TOOL_ITERATION_CAP >= 8 + + +class TestAToolThatRaisesDoesNotLoseTheTurn: + """The same failure as the cap, through a different door. + + Nothing wrapped the dispatch chain, so a raise anywhere in it escaped + get_reply, main.py answered the falsy reply with FALLBACK_SMS, and every + tool result already gathered went with it. In a three-intent turn one + failing DB write destroyed the two intents that had already succeeded. + """ + + def _run(self, search_side_effect, final_text="ok, here's what I have"): + calls = [] + + def _create(**kw): + calls.append(kw) + if len(calls) == 1: + return MagicMock(stop_reason="tool_use", content=_blocks(tool="web_search")) + return MagicMock(stop_reason="end_turn", content=_blocks(text=final_text)) + + with patch.object(agent.client.messages, "create", side_effect=_create), \ + patch.object(agent, "_build_system", return_value="sys"), \ + patch.object(agent, "get_profile", return_value={}), \ + patch.object(agent, "get_history", return_value=[]), \ + patch.object(agent, "_search", side_effect=search_side_effect): + out, _ = agent.get_reply("+15550001111", "what's the news", history=[]) + return out, calls + + def _tool_results(self, calls): + """The tool_result blocks handed back on the follow-up call.""" + out = [] + for m in calls[-1]["messages"]: + content = m.get("content") + if isinstance(content, list): + out += [c for c in content if isinstance(c, dict) + and c.get("type") == "tool_result"] + return out + + def test_the_turn_still_answers(self): + out, _ = self._run(RuntimeError("boom")) + assert out == "ok, here's what I have" + + def test_the_error_comes_back_as_a_tool_result(self): + _, calls = self._run(RuntimeError("boom")) + results = self._tool_results(calls) + # Every tool_use block must be answered or the next call is rejected. + assert len(results) == 1 + assert results[0]["tool_use_id"] == "tu_1" + assert "web_search" in results[0]["content"] + + def test_a_raise_is_still_loud_in_the_logs(self, capsys): + self._run(RuntimeError("boom")) + err = capsys.readouterr() + assert "TOOL web_search raised: RuntimeError: boom" in err.out + assert "Traceback" in err.err + + def test_keyboard_interrupt_is_not_swallowed(self): + """`except Exception`, never `BaseException` — a ctrl-c must still stop.""" + try: + self._run(KeyboardInterrupt()) + except KeyboardInterrupt: + return + assert False, "KeyboardInterrupt was swallowed by the tool guard" + + +class TestWhatTheModelIsToldWhenAToolRaises: + def test_it_never_claims_a_missing_capability(self): + from palmer import guards + out = agent._tool_error("search_flights", RuntimeError("x")) + assert not guards.redirects_elsewhere(out) + assert "not a missing capability" in out + + def test_it_does_not_leak_a_query_string(self): + """netutil re-raises the urllib error, and that URL carries the API key.""" + exc = RuntimeError( + "HTTP Error 500: https://serpapi.com/search.json?api_key=SECRETKEY&q=x") + out = agent._tool_error("search_shopping", exc) + assert "SECRETKEY" not in out + assert "api_key" not in out + assert "RuntimeError" in out # the type still reaches the model + + def test_an_argument_error_is_actionable(self): + """Our own strings carry no secrets and are the only ones it can fix.""" + out = agent._tool_error("set_reminder", KeyError("due_at")) + assert "due_at" in out + assert "once more" in out + + def test_the_cache_invalidate_guards_are_left_alone(self): + """They wrap a best-effort expiry AFTER a successful write. + + Folding them into the outer catch would turn a stale cache into a + tool error for an operation that actually succeeded — Palmer would + tell the user their topic wasn't added when it was.""" + import inspect + src = inspect.getsource(agent.get_reply) + assert src.count("home.invalidate after") >= 6 + + +# ============================================================================ +# from test_commute.py +# ============================================================================ +# +# The commute is routed for the leave time, not for the moment of the fetch. +# +# The morning job runs at the user's morning time and the page refreshes on tap, +# so a user who leaves at 8:30 used to be told the 7:00 number. Now a commute is +# saved by a tool (set_commute) that geocodes on the write path and may carry a +# leave time; the fetch routes for that departure when it is still ahead +# (TomTom departAt) and live otherwise, and every surface says which one it got. +# The page never renders the addresses — it is a tokenized URL and those are +# someone's home and office. + +CHI = ZoneInfo("America/Chicago") +ROUTE = {"routes": [{"summary": {"travelTimeInSeconds": 1020, + "noTrafficTravelTimeInSeconds": 960, + "trafficDelayInSeconds": 60, + "lengthInMeters": 22000}}]} +ORIGIN = "33 Cedarbrook Lane, Kirkwood MO 63122" +DEST = "1 Market St, St. Louis MO 63102" + + +class TestFriendlyTime: + def test_morning_afternoon_and_the_two_twelves(self): + assert friendly_hhmm("08:30") == "8:30am" + assert friendly_hhmm("17:05") == "5:05pm" + assert friendly_hhmm("12:00") == "12:00pm" + assert friendly_hhmm("00:05") == "12:05am" + + def test_never_raises_on_a_render_path(self): + assert friendly_hhmm("") == "" + assert friendly_hhmm(None) == "" + assert friendly_hhmm("noon") == "noon" + assert friendly_hhmm("25:00") == "25:00" + + +# What TomTom actually returns for a departAt route: the trip is a third +# slower than free-flow and trafficDelayInSeconds is 0, because that field +# counts live incidents and a prediction has none. +PREDICTED_ROUTE = {"routes": [{"summary": {"travelTimeInSeconds": 1740, + "noTrafficTravelTimeInSeconds": 1320, + "trafficDelayInSeconds": 0, + "lengthInMeters": 22000}}]} + + +def _snapshot(route=ROUTE, **kw): + """traffic_snapshot against a canned TomTom reply; returns (result, url).""" + seen = {} + + def _get(url, **_): + seen["url"] = url + return route + + with patch.object(traffic, "_http_get_json", side_effect=_get): + out = traffic.traffic_snapshot(ORIGIN, DEST, **kw) + return out, seen.get("url", "") + + +class TestSnapshotDeparture: + def setup_method(self): + traffic._addr_geo_cache.clear() + + def test_a_future_departure_is_routed_for_and_labelled(self): + depart = datetime.now(CHI) + timedelta(hours=2) + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, url = _snapshot(depart_at=depart, tz_name="America/Chicago") + assert "departAt=" in url + assert out["predicted"] is True + assert out["depart_at"] == depart.strftime("%H:%M") + assert out["arrive_at"] == (depart + timedelta(seconds=1020)).strftime("%H:%M") + + def test_the_stamp_is_percent_encoded(self): + """An offset east of UTC carries a "+", which decodes to a space in a + query string and TomTom 400s on it.""" + depart = datetime.now(ZoneInfo("Europe/Paris")) + timedelta(hours=2) + with patch.object(traffic, "_geocode_address", return_value=(48.8, 2.3)): + _, url = _snapshot(depart_at=depart, tz_name="Europe/Paris") + stamp = url.split("departAt=")[1] + assert "+" not in stamp and "%2B" in stamp and "%3A" in stamp + + def test_a_predicted_delay_is_live_minus_free_flow(self): + depart = datetime.now(CHI) + timedelta(hours=2) + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, _ = _snapshot(route=PREDICTED_ROUTE, depart_at=depart, tz_name="America/Chicago") + assert out["live_min"] == 29 and out["free_min"] == 22 + assert out["delay_min"] == 7, "a 29-vs-22 prediction must not read as 'normal'" + # A live route keeps TomTom's incident delay, unchanged from before. + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, _ = _snapshot(tz_name="America/Chicago") + assert out["delay_min"] == 1 + + def test_no_zone_means_no_arrival_clock(self): + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, _ = _snapshot(tz_name=None) + bad, _ = _snapshot(tz_name="Pacific Time") + ok, _ = _snapshot(tz_name="America/Chicago") + assert out["arrive_at"] is None and bad["arrive_at"] is None + assert ok["arrive_at"] and out["live_min"] == ok["live_min"] + + def test_a_past_departure_routes_live(self): + depart = datetime.now(CHI) - timedelta(minutes=10) + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, url = _snapshot(depart_at=depart, tz_name="America/Chicago") + assert "departAt" not in url + assert out["predicted"] is False and "depart_at" not in out + assert re.fullmatch(r"\d\d:\d\d", out["arrive_at"]) + + def test_a_naive_departure_is_ignored_rather_than_read_as_utc(self): + depart = datetime.now() + timedelta(hours=2) + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, url = _snapshot(depart_at=depart) + assert "departAt" not in url and out["predicted"] is False + + def test_no_departure_is_the_old_behaviour_plus_a_label(self): + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, url = _snapshot() + assert "departAt" not in url + assert out["live_min"] == 17 and out["free_min"] == 16 and out["delay_min"] == 1 + assert round(out["ratio"], 2) == 1.06 and out["miles"] == 13.7 + assert out["predicted"] is False + + def test_stored_coordinates_skip_the_geocoder(self): + with patch.object(traffic, "_geocode_address", side_effect=AssertionError("geocoded")): + out, url = _snapshot(origin_ll=[38.5, -90.4], dest_ll=[38.6, -90.2]) + assert "38.5,-90.4:38.6,-90.2" in url and out["live_min"] == 17 + + def test_a_legacy_string_commute_still_geocodes(self): + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)) as geo: + out, _ = _snapshot() + assert geo.call_count == 2 and out is not None + + def test_the_result_never_carries_the_addresses(self): + """The page renders whatever this returns, and the page has no auth.""" + with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): + out, _ = _snapshot(depart_at=datetime.now(CHI) + timedelta(hours=1)) + assert "origin" not in out and "destination" not in out + assert not any(ORIGIN in str(v) or DEST in str(v) for v in out.values()) + + +class TestAddressCache: + def setup_method(self): + traffic._addr_geo_cache.clear() + + def test_second_lookup_costs_nothing(self): + reply = {"results": [{"position": {"lat": 1.0, "lon": 2.0}}]} + with patch.object(traffic, "_http_get_json", return_value=reply) as http: + assert traffic._geocode_address("1 Main St") == (1.0, 2.0) + assert traffic._geocode_address(" 1 main st ") == (1.0, 2.0) + assert http.call_count == 1 + + def test_a_miss_is_not_cached(self): + with patch.object(traffic, "_http_get_json", return_value={"results": []}) as http: + assert traffic._geocode_address("nowhere") is None + assert traffic._geocode_address("nowhere") is None + assert http.call_count == 2 + + +class TestDepartureRule: + def _at(self, hhmm): + h, m = hhmm.split(":") + return datetime(2026, 9, 2, int(h), int(m), tzinfo=CHI) + + def test_a_leave_time_still_ahead_is_routed_for(self): + out = home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("07:00")) + assert out == self._at("08:30") and out.utcoffset() == timedelta(hours=-5) + + def test_inside_the_lead_or_already_past_routes_live(self): + assert home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("08:28")) is None + assert home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("09:00")) is None + + def test_no_or_bad_leave_time_routes_live(self): + assert home._commute_depart_at({}, "America/Chicago", now=self._at("07:00")) is None + assert home._commute_depart_at({"leave_time": "soonish"}, "America/Chicago", now=self._at("07:00")) is None + + def test_no_zone_routes_live_rather_than_predicting_for_utc(self): + # Without a resolvable zone "08:30" has no clock to sit on; predicting + # for 08:30Z and labelling it 8:30am would be confidently wrong. + assert home._commute_depart_at({"leave_time": "08:30"}, None) is None + assert home._commute_depart_at({"leave_time": "08:30"}, "Pacific Time") is None + + def test_fetch_forwards_the_stored_route_and_departure(self): + profile = {"timezone": "America/Chicago", + "commute": {"origin": ORIGIN, "destination": DEST, "leave_time": "08:30", + "origin_ll": [38.5, -90.4], "dest_ll": [38.6, -90.2]}} + depart = self._at("08:30") + with patch.object(home, "_commute_depart_at", return_value=depart), \ + patch("palmer.traffic.traffic_snapshot", return_value={"live_min": 3}) as snap: + assert home._fetch_traffic(profile) == {"live_min": 3} + kw = snap.call_args.kwargs + assert kw["depart_at"] == depart and kw["tz_name"] == "America/Chicago" + assert kw["origin_ll"] == [38.5, -90.4] and kw["dest_ll"] == [38.6, -90.2] + + def test_a_legacy_commute_passes_no_coordinates(self): + profile = {"commute": {"origin": ORIGIN, "destination": DEST}} + with patch("palmer.traffic.traffic_snapshot", return_value=None) as snap: + home._fetch_traffic(profile) + kw = snap.call_args.kwargs + assert kw["origin_ll"] is None and kw["dest_ll"] is None and kw["depart_at"] is None + + +class TestDigest: + def test_predicted_names_the_departure_and_arrival(self): + d = morning._payload_digest({"traffic": {"live_min": 34, "delay_min": 9, "predicted": True, + "depart_at": "08:30", "arrive_at": "09:04"}}) + assert "at 8:30am" in d and "predicted for that departure" in d + assert "34 min" in d and "9 min slower than normal" in d + assert "arriving about 9:04am" in d + + def test_live_says_so_and_invents_no_leave_time(self): + d = morning._payload_digest({"traffic": {"live_min": 20, "delay_min": 0, "predicted": False, + "arrive_at": "07:22"}}) + assert d.startswith("Commute right now: 20 min, normal") + assert "arriving about 7:22am" in d and "leave" not in d + + def test_the_drafter_is_told_which_moment_the_number_is_for(self): + src = inspect.getsource(morning.generate_morning_line) + assert "for THAT departure" in src and "don't invent a time" in src + + +class TestPage: + BASE = dict(city="Kirkwood, MO", weather={"temp_now": 71}, prices=[], headlines=[], + fetched={}, tracking={}) + + def _html(self, traffic_dict): + return page.render(dict(self.BASE, traffic=traffic_dict), token="tok", + image_url="https://x/y.png", page_url="https://x/h/tok") + + def test_predicted_card_shows_leave_and_arrive(self): + html = self._html({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, + "depart_at": "08:30", "arrive_at": "09:04"}) + assert "leaves 8:30am" in html and "arrives ~9:04am" in html + assert "34 min commute at 8:30am" in html # og:description + + def test_live_card_says_right_now(self): + html = self._html({"live_min": 17, "delay_min": 0, "ratio": 1.0, "predicted": False, + "arrive_at": "07:19"}) + assert "right now" in html and "leaves" not in html + assert "17 min commute" in html and "commute at" not in html + + def test_addresses_never_reach_the_page(self): + """Structural: the payload's traffic dict is whatever traffic_snapshot + returned, and that never carries the addresses. Belt and braces here.""" + html = self._html({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, + "depart_at": "08:30", "arrive_at": "09:04"}) + assert ORIGIN not in html and DEST not in html and "Cedarbrook" not in html + + +class TestCard: + def _payload(self, traffic_dict): + return {"city": "Kirkwood, MO", "timezone": "America/Chicago", + "weather": {"temp_now": 71, "high": 88, "low": 64, "description": "clear"}, + "traffic": traffic_dict, "prices": [], "headlines": [], "opening": []} + + def test_renders_with_the_departure_line_and_changes_the_fingerprint(self): + predicted = self._payload({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, + "depart_at": "08:30", "arrive_at": "09:04"}) + live = self._payload({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": False, + "arrive_at": "09:04"}) + png = artifacts.render_png("tok-commute-test", predicted) + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert artifacts._card_fingerprint(predicted) != artifacts._card_fingerprint(live) + + +class TestToolsAndRouting: + def test_both_tools_exist_and_set_takes_an_optional_leave_time(self): + s = tool_by_name("set_commute") + assert s is not None and tool_by_name("clear_commute") is not None + assert set(s["input_schema"]["required"]) == {"origin", "destination"} + assert "leave_time" in s["input_schema"]["properties"] + + def test_set_carries_the_landmark_warning_get_travel_time_has(self): + s, g = tool_by_name("set_commute")["description"], tool_by_name("get_travel_time")["description"] + assert "landmark" in s and "street address" in s + assert "Fenway Park" in s and "Fenway Park" in g + + def test_travel_time_no_longer_disclaims_storing_addresses(self): + g = tool_by_name("get_travel_time")["description"] + assert "don't store" not in g and "set_commute" in g + + def test_system_prompt_routes_the_regular_drive(self): + block = prompts.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] + assert "set_commute" in block and "clear_commute" in block + assert "every day" in block + + def test_commute_is_not_in_the_extractor_schema(self): + assert '"commute"' not in prompts.EXTRACT_PROMPT + + +def _drive_commute(tool_name, tool_input, geocodes=((38.5, -90.4), (38.6, -90.2)), key="k"): + _, result, (upsert, _key, _geo, invalidate) = drive_tool( + tool_name, tool_input, message="my commute", + patches=[patch.object(agent, "upsert_profile"), + patch.object(traffic, "TOMTOM_API_KEY", key), + patch.object(traffic, "_geocode_address", side_effect=list(geocodes)), + patch("palmer.home.invalidate")]) + saved = upsert.call_args[0][1] if upsert.called else None + return result, saved, invalidate + + +class TestSetDispatch: + def test_geocodes_on_the_write_path_and_expires_the_card(self): + result, saved, invalidate = _drive_commute("set_commute", + {"origin": ORIGIN, "destination": DEST, "leave_time": "8:30"}) + assert saved["commute"] == {"origin": ORIGIN, "destination": DEST, "leave_time": "08:30", + "origin_ll": [38.5, -90.4], "dest_ll": [38.6, -90.2]} + invalidate.assert_called_once_with("+1555", ("traffic",)) + assert "08:30" in result and "without reading the addresses back" in result + + def test_no_leave_time_saves_and_says_the_number_is_live(self): + result, saved, _ = _drive_commute("set_commute", {"origin": ORIGIN, "destination": DEST}) + assert "leave_time" not in saved["commute"] and saved["commute"]["origin_ll"] + assert "live" in result and "optional" in result + + def test_an_unresolvable_address_asks_rather_than_guesses(self): + result, saved, invalidate = _drive_commute("set_commute", {"origin": ORIGIN, "destination": DEST}, + geocodes=((38.5, -90.4), None)) + assert saved is None and "do not guess" in result and DEST in result + invalidate.assert_not_called() + + def test_a_bad_leave_time_saves_nothing(self): + result, saved, _ = _drive_commute("set_commute", + {"origin": ORIGIN, "destination": DEST, "leave_time": "half eight"}) + assert saved is None and "Nothing saved" in result and "HH:MM" in result + + def test_a_missing_key_is_not_reported_as_a_bad_address(self): + result, saved, _ = _drive_commute("set_commute", {"origin": ORIGIN, "destination": DEST}, key="") + assert saved is None and "Nothing saved" in result and "Couldn't find" not in result + + def test_clear_deletes_the_key_and_expires_the_card(self): + result, saved, invalidate = _drive_commute("clear_commute", {}) + assert saved == {"commute": None} + invalidate.assert_called_once_with("+1555", ("traffic",)) + + def test_source_shape(self): + src = inspect.getsource(agent.get_reply) + block = src.split('"set_commute"')[1].split('elif b.name == "clear_commute"')[0] + assert "_geocode_address" in block and "_normalize_hhmm" in block + assert "do not guess" in block and "do not name a maps app" in block + + +class TestExtractorGuard: + def _apply(self, stored, incoming): + profile = {"commute": stored} if stored is not None else {} + with patch.object(userprofile, "upsert_profile") as up, \ + patch.object(userprofile, "get_profile", return_value=profile), \ + patch.object(userprofile, "_derive_timezone", return_value=None): + userprofile._apply_profile_updates("+1555", profile, {"commute": incoming, "job": "x"}) + return up.call_args[0][1] if up.called else {} + + def test_a_tool_written_commute_survives_an_extractor_write(self): + written = self._apply({"origin": "a", "destination": "b", "origin_ll": [1, 2]}, + {"origin": "c", "destination": "d"}) + assert "commute" not in written and written.get("job") == "x" + + def test_a_legacy_commute_may_still_be_replaced(self): + written = self._apply({"origin": "a", "destination": "b"}, {"origin": "c", "destination": "d"}) + assert written.get("commute") == {"origin": "c", "destination": "d"} + + def test_an_absent_commute_is_written(self): + written = self._apply(None, {"origin": "c", "destination": "d"}) + assert written.get("commute") == {"origin": "c", "destination": "d"} + + +# ============================================================================ +# from test_add_price_watch_tool.py +# ============================================================================ +# +# add_price_watch's dispatch used to insert a row with baseline_price=NULL and +# defer baseline-setting to the next scheduler tick (12h later), unlike +# add_amazon_watch which seeds it immediately. If that first scheduler-side +# match ever failed, the baseline stayed NULL forever and run_price_watches +# could never reach the alert comparison — a real drop would just get silently +# recorded as the (late) baseline with no alert. Seed it at creation time, +# same as Amazon, so a bad match is visible immediately instead of silent. + +def _drive_price_watch(tool_input, check_price_result): + _, result, (save, set_baseline, _check) = drive_tool( + "add_price_watch", tool_input, message="track this for me", + profile={"timezone": "America/Chicago"}, + patches=[patch.object(agent, "save_price_watch", return_value=42), + patch.object(agent, "set_price_watch_baseline"), + patch("palmer.shopping.check_price", return_value=check_price_result)]) + return result, save, set_baseline + + +class TestBaselineSeededAtCreation: + def test_a_successful_match_seeds_the_baseline_immediately(self): + current = {"price": 29.99, "url": "https://example.com/p", "merchant": "Target"} + result, save, set_baseline = _drive_price_watch({"product_name": "Premier Protein Chocolate 30-pack"}, current) + save.assert_called_once() + set_baseline.assert_called_once_with(42, 29.99, "https://example.com/p", "Target") + assert "29.99" in result + + def test_a_failed_match_does_not_seed_a_baseline_but_still_creates_the_watch(self): + result, save, set_baseline = _drive_price_watch({"product_name": "some obscure item"}, None) + save.assert_called_once() + set_baseline.assert_not_called() + assert "couldn't pin down a confident match" in result.lower() + + +class TestTheWatchNamesWhatItMatched: + """add_amazon_watch echoes the resolved listing; this path echoed the + user's own words back. The match is picked by a model with no confidence + floor, so "AirPods" can baseline on Gen 2, Gen 4 or Pro — and a wrong pick + stayed invisible until an alert arrived about the wrong product.""" + + def test_the_matched_title_is_in_the_tool_result(self): + import inspect + from palmer import agent + block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ + .split("elif b.name")[0] + assert 'current.get("title")' in block + assert "It matched:" in block + + def test_the_model_is_told_to_say_it_out_loud(self): + """A resolved thing named only in the tool result is still invisible + to the person who can correct it.""" + import inspect + from palmer import agent + block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ + .split("elif b.name")[0] + assert "correct you" in block diff --git a/tests/test_amazon_watches.py b/tests/test_amazon_watches.py deleted file mode 100644 index c33d3ce..0000000 --- a/tests/test_amazon_watches.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Tests for Amazon price-watch logic. Pure logic + mocked SerpAPI/Haiku — -no real network or LLM calls. Run: pytest test_amazon_watches.py""" -from dotenv import load_dotenv -load_dotenv() - -from unittest.mock import patch, MagicMock - -from palmer import amazon -from palmer import shopping - - -class TestExtractPrice: - def test_extracted_price_number(self): - assert amazon._extract_price({"extracted_price": 42.99}) == 42.99 - - def test_extracted_price_int(self): - assert amazon._extract_price({"extracted_price": 30}) == 30.0 - - def test_price_string_with_dollar_sign(self): - assert amazon._extract_price({"price": "$54.00"}) == 54.0 - - def test_price_string_with_comma(self): - assert amazon._extract_price({"price": "$1,299.99"}) == 1299.99 - - def test_price_string_malformed_returns_none(self): - assert amazon._extract_price({"price": "call for price"}) is None - - def test_missing_price_returns_none(self): - assert amazon._extract_price({}) is None - - def test_non_dict_returns_none(self): - assert amazon._extract_price(None) is None - assert amazon._extract_price("not a dict") is None - - def test_extracted_price_wins_over_string(self): - # Both present — the numeric field is authoritative - assert amazon._extract_price({"extracted_price": 40.0, "price": "$99.99"}) == 40.0 - - -class TestSerpapiSearch: - def _payload(self, items): - return {"organic_results": items} - - def test_missing_key_returns_empty(self): - with patch("palmer.serpapi.API_KEY", ""): - assert amazon._serpapi_search("protein") == [] - - def test_empty_query_returns_empty(self): - with patch("palmer.serpapi.API_KEY", "fake"): - assert amazon._serpapi_search("") == [] - - def test_http_failure_returns_empty(self): - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=None): - assert amazon._serpapi_search("protein") == [] - - def test_skips_items_without_asin(self): - payload = self._payload([ - {"title": "no asin here", "extracted_price": 20.0}, - {"asin": "B01", "title": "has asin", "extracted_price": 25.0}, - ]) - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - out = amazon._serpapi_search("thing") - assert len(out) == 1 - assert out[0]["asin"] == "B01" - - def test_skips_items_without_price(self): - payload = self._payload([ - {"asin": "B01", "title": "priced", "extracted_price": 20.0}, - {"asin": "B02", "title": "no price"}, - ]) - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - out = amazon._serpapi_search("thing") - assert [r["asin"] for r in out] == ["B01"] - - def test_falls_back_to_dp_url_when_missing_link(self): - payload = self._payload([ - {"asin": "B0ABC123", "title": "t", "extracted_price": 10.0}, - ]) - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - out = amazon._serpapi_search("thing") - assert out[0]["url"] == "https://www.amazon.com/dp/B0ABC123" - - -class TestPickBestMatch: - def _haiku_reply(self, text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - def _candidates(self): - return [ - {"asin": "B01", "title": "Optimum Nutrition Gold Standard 5lb", "price": 54.0, "url": ""}, - {"asin": "B02", "title": "Optimum Nutrition Gold Standard 2lb", "price": 32.0, "url": ""}, - ] - - def test_empty_candidates_returns_none(self): - assert amazon._pick_best_match("whey", []) is None - - def test_picks_indexed_candidate(self): - with patch("palmer.amazon.client") as mock_client: - mock_client.messages.create.return_value = self._haiku_reply("0") - out = amazon._pick_best_match("Optimum Nutrition 5lb", self._candidates()) - assert out["asin"] == "B01" - - def test_none_reply_returns_none(self): - with patch("palmer.amazon.client") as mock_client: - mock_client.messages.create.return_value = self._haiku_reply("NONE") - assert amazon._pick_best_match("random", self._candidates()) is None - - def test_out_of_range_index_returns_none(self): - with patch("palmer.amazon.client") as mock_client: - mock_client.messages.create.return_value = self._haiku_reply("99") - assert amazon._pick_best_match("q", self._candidates()) is None - - def test_haiku_exception_returns_none(self): - with patch("palmer.amazon.client") as mock_client: - mock_client.messages.create.side_effect = RuntimeError("boom") - assert amazon._pick_best_match("q", self._candidates()) is None - - -class TestExtractAsin: - def test_empty_returns_none(self): - assert amazon._extract_asin("") is None - assert amazon._extract_asin(None) is None - - def test_plain_text_returns_none(self): - assert amazon._extract_asin("protein shakes I like") is None - - def test_bare_amazon_url_extracts(self): - assert amazon._extract_asin("https://www.amazon.com/dp/B0ABCDEFGH") == "B0ABCDEFGH" - - def test_amazon_url_with_slug_extracts(self): - url = "https://www.amazon.com/Optimum-Nutrition-Standard-Whey-Chocolate/dp/B000QSNYGI/ref=xyz" - assert amazon._extract_asin(url) == "B000QSNYGI" - - def test_gp_product_url_extracts(self): - assert amazon._extract_asin("https://www.amazon.com/gp/product/B01AABBCCD?psc=1") == "B01AABBCCD" - - def test_url_embedded_in_text_extracts(self): - assert amazon._extract_asin("check this out https://www.amazon.com/dp/B0ZZ111111 pretty good") == "B0ZZ111111" - - def test_lowercase_asin_does_not_match(self): - # Amazon ASINs are always uppercase; a lowercase 10-char string is not an ASIN. - assert amazon._extract_asin("https://www.amazon.com/dp/abcdefghij") is None - - def test_a_co_short_url_follows_redirect(self): - # Simulate a.co redirecting to the canonical amazon.com/dp/… URL - with patch("palmer.amazon._resolve_short_url", - return_value="https://www.amazon.com/dp/B0XYZ12345?ref=short"): - assert amazon._extract_asin("https://a.co/d/08q64W9B") == "B0XYZ12345" - - def test_amzn_to_short_url_follows_redirect(self): - with patch("palmer.amazon._resolve_short_url", - return_value="https://www.amazon.com/gp/product/B0AMZTOAAA"): - assert amazon._extract_asin("here: https://amzn.to/3abcXYZ") == "B0AMZTOAAA" - - def test_short_url_redirect_failure_returns_none(self): - with patch("palmer.amazon._resolve_short_url", return_value=None): - assert amazon._extract_asin("https://a.co/d/broken") is None - - def test_short_url_redirects_to_non_product_page(self): - # a.co could resolve to an amazon.com homepage / cart / list URL — - # if there's no /dp/ in the final URL, return None. - with patch("palmer.amazon._resolve_short_url", - return_value="https://www.amazon.com/hz/wishlist/ls/XYZ"): - assert amazon._extract_asin("https://a.co/d/anythg") is None - - -class TestResolveAsin: - def _search_result(self): - return [{"asin": "B0ABC", "title": "thing", "price": 42.0, "url": "https://amazon.com/dp/B0ABC"}] - - def test_no_match_returns_none(self): - with patch("palmer.amazon._extract_asin", return_value=None), \ - patch("palmer.amazon._serpapi_search", return_value=[]), \ - patch("palmer.amazon._pick_best_match", return_value=None): - assert amazon.resolve_asin("nonsense") is None - - def test_pick_returns_watchable_dict(self): - with patch("palmer.amazon._extract_asin", return_value=None), \ - patch("palmer.amazon._serpapi_search", return_value=self._search_result()), \ - patch("palmer.amazon._pick_best_match", return_value=self._search_result()[0]): - out = amazon.resolve_asin("thing") - assert out["asin"] == "B0ABC" - assert out["title"] == "thing" - assert out["price"] == 42.0 - assert out["merchant"] == "Amazon" - assert out["url"].endswith("B0ABC") - - def test_url_fast_path_skips_search(self): - """When the user pastes an Amazon URL, resolve_asin extracts the ASIN - and hits amazon_product directly — no SerpAPI search, no Haiku pick.""" - product = {"title": "Optimum Nutrition Gold Standard", "price": 54.0} - with patch("palmer.amazon._extract_asin", return_value="B000QSNYGI"), \ - patch("palmer.amazon._amazon_product", return_value=product) as amp, \ - patch("palmer.amazon._serpapi_search") as search, \ - patch("palmer.amazon._pick_best_match") as pick: - out = amazon.resolve_asin("https://www.amazon.com/dp/B000QSNYGI") - assert out == { - "asin": "B000QSNYGI", - "title": "Optimum Nutrition Gold Standard", - "price": 54.0, - "url": "https://www.amazon.com/dp/B000QSNYGI", - "merchant": "Amazon", - } - amp.assert_called_once_with("B000QSNYGI") - search.assert_not_called() # URL path skips search - pick.assert_not_called() - - def test_url_fast_path_product_lookup_fails(self): - """If the URL parses but amazon_product returns None, don't silently - fall back to searching the URL string — return None so the caller can - tell the user something went wrong with this specific listing.""" - with patch("palmer.amazon._extract_asin", return_value="B0DEADBEEF"), \ - patch("palmer.amazon._amazon_product", return_value=None), \ - patch("palmer.amazon._serpapi_search") as search: - assert amazon.resolve_asin("https://www.amazon.com/dp/B0DEADBEEF") is None - search.assert_not_called() - - -class TestCheckPrice: - """check_price hits amazon_product with the stored ASIN, not the original query.""" - - def _watch(self, **kw): - base = {"id": 1, "asin": "B0PROT", "product_name": "Protein"} - base.update(kw) - return base - - def test_missing_asin_returns_none(self): - with patch("palmer.serpapi.API_KEY", "fake"): - assert amazon.check_price(self._watch(asin=None)) is None - - def test_missing_key_returns_none(self): - with patch("palmer.serpapi.API_KEY", ""): - assert amazon.check_price(self._watch()) is None - - def test_http_failure_returns_none(self): - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=None): - assert amazon.check_price(self._watch()) is None - - def test_reads_product_results_price(self): - payload = {"product_results": {"title": "Full title", "extracted_price": 42.0}} - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - out = amazon.check_price(self._watch()) - assert out["price"] == 42.0 - assert out["title"] == "Full title" - assert out["merchant"] == "Amazon" - assert out["url"] == "https://www.amazon.com/dp/B0PROT" - - def test_falls_back_to_buybox_when_product_results_priceless(self): - payload = {"product_results": {"title": "T"}, "buybox_winner": {"extracted_price": 39.99}} - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - out = amazon.check_price(self._watch()) - assert out["price"] == 39.99 - - def test_no_price_anywhere_returns_none(self): - payload = {"product_results": {"title": "T"}} - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", return_value=payload): - assert amazon.check_price(self._watch()) is None - - def test_uses_asin_not_query(self): - """Regression: check_price MUST send the ASIN, not the product name. - Rebuilding on the phrase would drift over time as Amazon's ranking changes.""" - captured = {} - def _capture(url, timeout): - captured["url"] = url - return {"product_results": {"extracted_price": 10.0}} - with patch("palmer.serpapi.API_KEY", "fake"), \ - patch("palmer.serpapi._http_get_json", side_effect=_capture): - amazon.check_price(self._watch(asin="B0XYZ", product_name="totally different")) - assert "asin=B0XYZ" in captured["url"] - assert "totally+different" not in captured["url"] - - -class TestDraftAlert: - def _watch(self, **kw): - base = {"target_price": None, "baseline_price": 60.0} - base.update(kw) - return base - - def _current(self, **kw): - base = {"price": 42.0, "url": "https://www.amazon.com/dp/B0PROT"} - base.update(kw) - return base - - # Drafting moved to price_alert (shared with shopping), so the model client - # to patch lives there — patching amazon.client here silently let these make - # real API calls. - def test_appends_url_even_on_draft_failure(self): - with patch("palmer.price_alert.client") as mock_client: - mock_client.messages.create.side_effect = RuntimeError("boom") - body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") - assert body.endswith("https://www.amazon.com/dp/B0PROT") - - def test_appends_url_on_success(self): - block = MagicMock() - block.text = "your protein just dropped to $42, was $60" - resp = MagicMock() - resp.content = [block] - with patch("palmer.price_alert.client") as mock_client: - mock_client.messages.create.return_value = resp - body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") - assert body.endswith("https://www.amazon.com/dp/B0PROT") - assert "$42" in body - - -class TestRunPriceWatchesDispatch: - """The critical integration point: shopping-source rows go to shopping.check_price; - amazon-source rows go to amazon.check_price. A mistake here corrupts baselines silently.""" - - def _amazon_watch(self): - return { - "id": 1, "phone": "+15551234567", "product_name": "Protein", - "source": "amazon", "asin": "B0ABC", - "target_price": None, "baseline_price": None, - "last_seen_price": None, "last_seen_url": None, "last_seen_merchant": None, - "cooldown_hours": 12, "last_alerted": None, "last_alert_summary": None, - "currency": "USD", - } - - def _shopping_watch(self): - return { - "id": 2, "phone": "+15551234567", "product_name": "Sneakers", - "source": "shopping", "asin": None, - "target_price": None, "baseline_price": None, - "last_seen_price": None, "last_seen_url": None, "last_seen_merchant": None, - "cooldown_hours": 12, "last_alerted": None, "last_alert_summary": None, - "currency": "USD", - } - - def test_amazon_row_routes_to_amazon_check_price(self): - amz = self._amazon_watch() - shp = self._shopping_watch() - current = {"price": 40.0, "title": "t", "merchant": "Amazon", - "url": "https://www.amazon.com/dp/B0ABC"} - with patch("palmer.db.get_active_price_watches", return_value=[amz, shp]), \ - patch("palmer.amazon.check_price", return_value=current) as amz_check, \ - patch("palmer.shopping.check_price", return_value=None) as shp_check, \ - patch("palmer.db.set_price_watch_baseline") as set_baseline, \ - patch("palmer.sms_util.ensure_sms", return_value=True): - shopping.run_price_watches() - # Amazon path called with the full watch dict (needs the ASIN) - amz_check.assert_called_once_with(amz) - # Shopping path called with the product name string - shp_check.assert_called_once_with(shp["product_name"]) - # Baseline seed happens for the Amazon row (baseline was None) - set_baseline.assert_called_once() - assert set_baseline.call_args[0][0] == amz["id"] - - def test_missing_source_defaults_to_shopping_path(self): - """Older rows written before the source column existed have no source - set. Dispatch must treat them as shopping, not skip or crash.""" - legacy = self._shopping_watch() - legacy.pop("source") # simulate a pre-migration row shape - with patch("palmer.db.get_active_price_watches", return_value=[legacy]), \ - patch("palmer.amazon.check_price") as amz_check, \ - patch("palmer.shopping.check_price", return_value=None) as shp_check, \ - patch("palmer.db.set_price_watch_baseline"): - shopping.run_price_watches() - amz_check.assert_not_called() - shp_check.assert_called_once_with(legacy["product_name"]) - - def test_amazon_alert_uses_amazon_draft_alert(self): - """When the alert fires on an Amazon row, the Amazon drafter (URL-inclusive) - runs — not the shopping drafter (URL-less).""" - amz = self._amazon_watch() - amz["baseline_price"] = 60.0 # already seeded; a drop should alert - current = {"price": 40.0, "title": "t", "merchant": "Amazon", - "url": "https://www.amazon.com/dp/B0ABC"} - with patch("palmer.db.get_active_price_watches", return_value=[amz]), \ - patch("palmer.amazon.check_price", return_value=current), \ - patch("palmer.amazon.draft_alert", return_value="alert body https://www.amazon.com/dp/B0ABC") as amz_draft, \ - patch("palmer.shopping._draft_alert") as shop_draft, \ - patch("palmer.sms_util.ensure_sms", return_value=True), \ - patch("palmer.db.claim_price_watch_alert", return_value=True), \ - patch("palmer.db.update_price_watch_alerted"): - shopping.run_price_watches() - amz_draft.assert_called_once() - shop_draft.assert_not_called() diff --git a/tests/test_arrange_page_tool.py b/tests/test_arrange_page_tool.py deleted file mode 100644 index 25fc68c..0000000 --- a/tests/test_arrange_page_tool.py +++ /dev/null @@ -1,106 +0,0 @@ -"""arrange_page dispatch: presentation prefs merge by set arithmetic (a delta, -never a model-restated whole set), unknown section words are surfaced rather -than guessed, and the prices cache is expired only when the SORT changed — -order and visibility are render-time and need no invalidate.""" -from unittest.mock import patch - -from palmer import agent - - -class _Block: - def __init__(self, **kw): - self.__dict__.update(kw) - - -class _Resp: - def __init__(self, content, stop_reason): - self.content, self.stop_reason = content, stop_reason - - -def _drive(tool_input, profile=None): - calls = [] - responses = [ - _Resp([_Block(type="tool_use", name="arrange_page", id="t1", input=tool_input)], "tool_use"), - _Resp([_Block(type="text", text="done")], "end_turn"), - ] - - def _create(**kw): - calls.append(kw) - return responses[len(calls) - 1] - - with patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "get_profile", return_value=profile or {}), \ - patch.object(agent, "upsert_profile") as upsert, \ - patch("palmer.home.invalidate") as invalidate, \ - patch.object(agent.client.messages, "create", side_effect=_create): - agent.get_reply("+1555", "arrange my page") - result = calls[1]["messages"][-1]["content"][0]["content"] - saved = upsert.call_args[0][1]["morning_prefs"] if upsert.called else None - return result, saved, invalidate - - -class TestMarketsSort: - def test_movers_is_stored_and_expires_the_prices_cache(self): - result, saved, invalidate = _drive({"markets_sort": "movers"}) - assert saved["markets_sort"] == "movers" - invalidate.assert_called_once_with("+1555", ("prices",)) - - def test_added_clears_the_key_rather_than_storing_a_default(self): - """Absent means topic order already; a stored default is prompt noise.""" - _, saved, invalidate = _drive({"markets_sort": "added"}, - {"morning_prefs": {"markets_sort": "movers"}}) - assert "markets_sort" not in saved - invalidate.assert_called_once_with("+1555", ("prices",)) - - def test_restating_the_current_sort_does_not_expire_the_cache(self): - _, saved, invalidate = _drive({"markets_sort": "movers"}, - {"morning_prefs": {"markets_sort": "movers"}}) - invalidate.assert_not_called() - - -class TestOrderAndVisibility: - def test_order_words_are_canonicalized(self): - _, saved, invalidate = _drive({"section_order": ["stocks", "headlines"]}) - assert saved["section_order"] == ["markets", "news"] - - def test_order_changes_do_not_expire_any_cache(self): - """Render-time — carried onto the payload on every view.""" - _, _, invalidate = _drive({"section_order": ["markets"]}) - invalidate.assert_not_called() - - def test_hide_then_show_round_trips(self): - _, saved, _ = _drive({"hide": ["traffic"]}) - assert saved["hidden_sections"] == ["commute"] - _, saved, _ = _drive({"show": ["commute"]}, - {"morning_prefs": {"hidden_sections": ["commute"]}}) - assert saved["hidden_sections"] == [] - - def test_hide_is_a_delta_not_a_restatement(self): - """An existing hidden section survives a hide it wasn't named in.""" - _, saved, _ = _drive({"hide": ["news"]}, - {"morning_prefs": {"hidden_sections": ["commute"]}}) - assert set(saved["hidden_sections"]) == {"commute", "news"} - - def test_other_prefs_survive_the_merge(self): - _, saved, _ = _drive({"hide": ["news"]}, - {"morning_prefs": {"episode_alerts": True, - "opening_kinds": ["local"]}}) - assert saved["episode_alerts"] is True - assert saved["opening_kinds"] == ["local"] - - -class TestUnknownWords: - def test_an_unknown_word_is_surfaced_not_guessed(self): - result, saved, _ = _drive({"hide": ["horoscope"]}) - assert "horoscope" in result - assert "ask" in result.lower() - assert saved is None, "nothing recognizable, nothing written" - - def test_kind_words_do_not_hide_the_opening_section(self): - """'movies' and 'concerts' are Opening KINDS (opening_remove's job); - mapping them here would let 'hide movies' silently hide the whole - section instead of trimming a kind.""" - result, saved, _ = _drive({"hide": ["movies"]}) - assert saved is None - assert "movies" in result diff --git a/tests/test_briefing_shape.py b/tests/test_briefing_shape.py deleted file mode 100644 index 6d810fa..0000000 --- a/tests/test_briefing_shape.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Tests for the two bugs behind the 'Palmer dumped a briefing on me' report. - -1. A greeting must not produce briefing content — the briefing is scheduled, - not assembled on request. -2. Briefing configuration must not leak into ordinary replies. One user had - "Format: bullet points per subject" saved as a morning topic; the profile is - dumped as raw JSON into every system prompt, so it read as an order for the - current message and turned normal replies into labelled dumps. It was also - being sent to the news search as a query. -""" -from unittest.mock import patch - -from palmer import agent -from palmer import morning -from palmer import prompts - - -class TestGreetingIsNotABriefing: - def test_prompt_says_the_briefing_is_sent_not_assembled(self): - body = prompts.SYSTEM_PROMPT - assert "It is NOT something you assemble on request" in body - - def test_prompt_names_the_greeting_case(self): - body = prompts.SYSTEM_PROMPT.lower() - assert "a greeting is a greeting" in body - assert "they said hello; say hello back" in body - - def test_prompt_holds_even_before_the_briefing_has_gone_out(self): - """The 6:22am case: briefing not sent yet, so the pull is strongest.""" - assert "even when their briefing hasn't gone out yet today" in prompts.SYSTEM_PROMPT - - def test_prompt_bans_the_shapes_that_actually_appeared(self): - body = prompts.SYSTEM_PROMPT - assert "Here's your Thursday" in body, "the observed opener must be named" - assert "Weather -" in body and "Commute -" in body, "labelled sections must be named" - assert "anything you want me to dig into" in body.lower() - - -class TestBriefingConfigDoesNotLeakIntoReplies: - def _build(self, profile): - with patch.object(agent, "get_profile", return_value=profile), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - return agent._build_system("+15550001111") - - def test_topics_are_labelled_as_reference_data(self): - out = self._build({"morning_topics": ["St. Louis weather", "SpaceX news"]}) - assert "reference data, not instructions for this message" in out - assert "Never let it change how you write a reply" in out - - def test_no_such_note_without_topics(self): - assert "reference data, not instructions" not in self._build({"name": "Mike"}) - - def test_topics_still_visible_so_palmer_can_answer_what_am_i_getting(self): - out = self._build({"morning_topics": ["SpaceX news"]}) - assert "SpaceX news" in out - - def test_directive_is_stripped_from_the_prompt_entirely(self): - """Labelling it as data was not enough — the model still obeyed it.""" - out = self._build({"morning_topics": [ - "SpaceX news", "Format: bullet points per subject, not one continuous paragraph", - ]}) - assert "SpaceX news" in out, "real topics must survive" - assert "bullet points per subject" not in out, \ - "a stored formatting directive must never reach the reply prompt" - - def test_profile_is_not_mutated(self): - profile = {"morning_topics": ["SpaceX news", "Format: bullets"]} - agent._prompt_safe_profile(profile) - assert len(profile["morning_topics"]) == 2, "must not edit the stored profile" - - def test_untouched_when_nothing_to_strip(self): - """Content, not identity. The profile is always copied now — volatile - facts are dated or dropped on the way to the prompt — so returning the - same object is no longer the contract. Not mutating the stored one is, - and that is asserted above.""" - profile = {"morning_topics": ["SpaceX news"], "name": "Jeff"} - assert agent._prompt_safe_profile(profile) == profile - - -class TestDirectivesAreNotTopics: - def test_format_directive_detected(self): - assert morning._is_directive("Format: bullet points per subject, not one continuous paragraph") - - def test_real_topics_are_not_directives(self): - for t in ("SpaceX news", "St. Louis Cardinals baseball news", - "Bitcoin and major stock news", "Daily fun fact from history"): - assert not morning._is_directive(t), t - - def test_case_and_whitespace_insensitive(self): - assert morning._is_directive(" FORMAT: bullet points ") - - def test_directive_never_reaches_the_news_search(self): - searched = [] - profile = {"city": "", "morning_topics": [ - "Format: bullet points per subject", "SpaceX news", - ]} - with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ - patch.object(morning, "_get_price", return_value="x"), \ - patch("palmer.trends.adjacent_story", return_value=None): - morning._gather_morning_data(profile) - assert "SpaceX news" in searched - assert not any("Format:" in s for s in searched), \ - "a formatting preference was sent to Tavily as a news query" - - -class TestTopicCoverage: - def test_cap_covers_a_realistic_subscription(self): - """8 real topics used to silently become 3.""" - assert morning.MAX_TOPICS >= 6 - - def test_all_topics_up_to_the_cap_are_pulled(self): - searched = [] - profile = {"city": "", "morning_topics": [f"topic {i}" for i in range(8)]} - with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ - patch.object(morning, "_get_price", return_value="x"), \ - patch("palmer.trends.adjacent_story", return_value=None): - morning._gather_morning_data(profile) - assert len(searched) == morning.MAX_TOPICS - - -class TestBriefingShapeRules: - def test_prompt_forbids_subject_labels(self): - import inspect - src = inspect.getsource(morning.generate_morning) - assert "Never label a line with its subject" in src - assert "Cardinals - lost 5-4" in src, "the observed bad shape should be the example" - - -class TestCommuteRoute: - """Palmer promised 'live drive time from Cedarbrook to Carondelet Plaza' but - morning.py only ever called get_city_traffic(city), which gives area-wide - conditions. The route tool existed; it just wasn't wired in.""" - - TOPIC = ("Daily commute traffic: 33 Cedarbrook Lane, Kirkwood MO 63122 " - "to 190 Carondelet Plaza, Clayton MO 63105") - - def test_parses_the_shape_users_actually_save(self): - assert morning._commute_route({"morning_topics": [self.TOPIC]}) == ( - "33 Cedarbrook Lane, Kirkwood MO 63122", "190 Carondelet Plaza, Clayton MO 63105") - - def test_structured_field_wins_over_topic_text(self): - route = morning._commute_route({ - "commute": {"origin": "1 Main St, Springfield", "destination": "2 Oak Ave, Shelbyville"}, - "morning_topics": [self.TOPIC], - }) - assert route == ("1 Main St, Springfield", "2 Oak Ave, Shelbyville") - - def test_no_route_when_none_saved(self): - assert morning._commute_route({"morning_topics": ["SpaceX news"]}) is None - - def test_plain_traffic_topic_is_not_a_route(self): - assert morning._commute_route({"morning_topics": ["St. Louis traffic"]}) is None - - def test_uses_route_not_city_when_available(self): - with patch.object(morning, "get_travel_time", return_value="22 minutes, 13 miles. 3 over normal.") as route, \ - patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ - patch.object(morning, "_weather_report", return_value="warm"), \ - patch.object(morning, "_topic_digest", return_value="x"), \ - patch("palmer.trends.adjacent_story", return_value=None): - out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) - route.assert_called_once() - city.assert_not_called() - assert any("22 minutes" in s for s in out) - - def test_falls_back_to_city_without_a_route(self): - with patch.object(morning, "get_travel_time") as route, \ - patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ - patch.object(morning, "_weather_report", return_value="warm"), \ - patch.object(morning, "_topic_digest", return_value="x"), \ - patch("palmer.trends.adjacent_story", return_value=None): - morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) - route.assert_not_called() - city.assert_called_once() - - def test_routing_failure_falls_back_instead_of_leaking_the_error(self): - """get_travel_time returns its errors as strings — those must never - reach a briefing as if they were traffic.""" - for failure in ("Couldn't find that starting address: '33 Cedarbrook'.", - "Routing failed for 'a' → 'b'.", - "Traffic API is not configured."): - with patch.object(morning, "get_travel_time", return_value=failure), \ - patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ - patch.object(morning, "_weather_report", return_value="warm"), \ - patch.object(morning, "_topic_digest", return_value="x"), \ - patch("palmer.trends.adjacent_story", return_value=None): - out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) - city.assert_called_once() - assert not any("Couldn't find" in s or "Routing failed" in s for s in out) - - def test_route_line_ok(self): - assert morning._route_line_ok("17 minutes, 13.7 miles. Basically free-flow.") - assert not morning._route_line_ok("") - assert not morning._route_line_ok(None) - assert not morning._route_line_ok("Need both an origin and destination address to route.") - - def test_commute_is_a_tool_not_an_extracted_field(self): - """set_commute stores coordinates and a leave time the extractor knows - nothing about, so the field left EXTRACT_PROMPT the way followed_teams - and shows did: a Haiku write of {origin, destination} would replace the - tool's dict and drop both.""" - from palmer import prompts - from palmer.tools_def import TOOLS - assert '"commute"' not in prompts.EXTRACT_PROMPT - assert "set_commute" in prompts.SYSTEM_PROMPT - assert any(t["name"] == "set_commute" for t in TOOLS) diff --git a/tests/test_calibration.py b/tests/test_calibration.py deleted file mode 100644 index 43986df..0000000 --- a/tests/test_calibration.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Tests for register calibration — Palmer adapting HOW he sounds to who's texting. - -The personality lives in prose inside SYSTEM_PROMPT, so these are structural -assertions in the style of test_rubrics.py: the sections exist, they say the -load-bearing thing, and the profile signal actually reaches the prompt. - -The ordering test is not cosmetic. SOUND CHECK is nine examples in one cultural -register, and few-shot beats instructions — SAME PALMER, DIFFERENT PEOPLE has to -come after it to be the last word on voice. -""" -from unittest.mock import patch, MagicMock - -from palmer import agent -from palmer import prompts -from palmer import reminders - - -def _render() -> str: - """SYSTEM_PROMPT as _build_system renders it.""" - return agent.SYSTEM_PROMPT.format(clock_block="RIGHT NOW\n(clock)", - profile_block="(none)") - - -class TestCalibrationSection: - def test_section_exists(self): - assert "CALIBRATION\n" in agent.SYSTEM_PROMPT - - def test_names_all_four_axes(self): - body = agent.SYSTEM_PROMPT - for axis in ("Irony tolerance", "Precision", "Formality and idiom", "Directness"): - assert axis in body, f"calibration axis {axis!r} missing" - - def test_spine_is_non_negotiable(self): - """Calibrating must never be licence to become a neutral assistant.""" - body = agent.SYSTEM_PROMPT.lower() - assert "the spine doesn't" in body - assert "neutral assistant" in body - - def test_does_not_announce_the_adjustment(self): - assert "never announce that you're adjusting" in agent.SYSTEM_PROMPT.lower() - - -class TestRangeExamples: - def test_block_exists(self): - assert "SAME PALMER, DIFFERENT PEOPLE" in agent.SYSTEM_PROMPT - - def test_comes_after_sound_check(self): - body = agent.SYSTEM_PROMPT - assert body.index("SOUND CHECK") < body.index("SAME PALMER, DIFFERENT PEOPLE"), \ - "range examples must follow SOUND CHECK or the single-register block wins" - - def test_sound_check_is_labelled_as_one_register(self): - """The original nine examples must not read as the only way to sound.""" - head = agent.SYSTEM_PROMPT.split("SOUND CHECK\n", 1)[1].split("them:", 1)[0] - assert head.strip(), "SOUND CHECK has no lead-in labelling it as one register" - assert "one register" in head.lower() - - def test_covers_distinct_registers(self): - block = agent.SYSTEM_PROMPT.split("SAME PALMER, DIFFERENT PEOPLE", 1)[1] \ - .split("NEW USERS", 1)[0] - assert "Poisson" in block, "no precise/technical register example" - assert "second language" in block, "no formal/ESL register example" - assert "rough day" in block, "no bad-day register example" - - -class TestPromptStillRenders: - def test_format_does_not_raise(self): - """SYSTEM_PROMPT is .format()ed — a stray brace in new prose breaks every reply.""" - _render() - - def test_placeholders_survive(self): - out = _render() - assert "RIGHT NOW\n(clock)" in out - assert "(none)" in out - - -class TestBuildSystemWiring: - def _build(self, profile: dict) -> str: - with patch.object(agent, "get_profile", return_value=profile), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - return agent._build_system("+15550001111") - - def test_communication_style_reaches_the_prompt_as_a_directive(self): - out = self._build({"name": "Ada", - "communication_style": "precise, wants the answer first"}) - assert "CALIBRATION READ" in out - assert "precise, wants the answer first" in out - assert "mirror it" in out - - def test_explicit_request_outranks_inference(self): - out = self._build({"communication_style": "asked directly for less sarcasm"}) - assert "outranks" in out - - def test_absent_style_adds_no_calibration_read(self): - assert "CALIBRATION READ" not in self._build({"name": "Ada"}) - - def test_blank_style_adds_no_calibration_read(self): - assert "CALIBRATION READ" not in self._build({"communication_style": " "}) - - def test_empty_profile_still_builds(self): - assert "CALIBRATION READ" not in self._build({}) - - -class TestOnboardingAsk: - """Message 1 never demands name/city (NEW USERS rule, above). From message - 2 on — intro already sent — if Palmer still doesn't know one or both, the - dynamically-appended ONBOARDING ASK block tells him to work it in, once.""" - - def _build(self, profile: dict, is_new_user: bool = False) -> str: - with patch.object(agent, "get_profile", return_value=profile), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - return agent._build_system("+15550001111", is_new_user=is_new_user) - - def test_asks_for_both_when_both_are_missing(self): - out = self._build({"intro_sent": True}) - assert "ONBOARDING ASK" in out - assert "name and what city" in out - - def test_asks_only_for_the_one_still_missing(self): - out = self._build({"intro_sent": True, "name": "Ada"}) - assert "ONBOARDING ASK" in out - assert "their city" in out - assert "name and what city" not in out - - def test_silent_on_the_very_first_message(self): - """Message 1 is is_new_user=True — the NEW USERS rules own that reply, - not this block, even if the profile happens to be empty.""" - assert "ONBOARDING ASK" not in self._build({}, is_new_user=True) - - def test_silent_once_name_and_city_are_both_known(self): - out = self._build({"intro_sent": True, "name": "Ada", "city": "Chicago"}) - assert "ONBOARDING ASK" not in out - - def test_silent_once_already_asked(self): - """Consumed once by userprofile._update_profile — see test_profile_schema.py.""" - out = self._build({"intro_sent": True, "onboarding_ask_sent": True}) - assert "ONBOARDING ASK" not in out - - def test_never_volunteers_the_link(self): - out = self._build({"intro_sent": True}) - block = out.split("ONBOARDING ASK", 1)[1].lower() - assert "send any link" in block or "don't mention their page" in block - - -class TestExtractionCapturesRegister: - def test_prompt_asks_for_explicit_requests(self): - body = prompts.EXTRACT_PROMPT.lower() - assert "communication_style" in body - assert "verbatim" in body, "explicit register asks must be recorded as stated" - assert "joke back" in body - - -class TestReminderPathHonoursStyle: - """Reminders used to carry their own mini-persona and never saw SYSTEM_PROMPT. - They now go through _build_system like every other user-facing message.""" - - def test_reminder_uses_the_shared_system_prompt(self): - captured = {} - - def _fake_create(**kwargs): - captured.update(kwargs) - block = MagicMock() - block.text = "hey - dentist at 3" - resp = MagicMock() - resp.content = [block] - return resp - - with patch.object(reminders, "_build_system", - return_value="SYSTEM WITH CALIBRATION READ: blunt") as bs, \ - patch.object(reminders.client.messages, "create", side_effect=_fake_create): - out = reminders._personalize_reminder( - "+15550001111", "dentist at 3", - {"communication_style": "blunt, asked for just the facts"}, - ) - - assert out - bs.assert_called_once() - assert captured["system"] == "SYSTEM WITH CALIBRATION READ: blunt" - assert captured["model"] == reminders.SONNET_MODEL, \ - "user-facing drafting belongs on Sonnet" - - def test_reminder_survives_a_drafting_failure(self): - with patch.object(reminders, "_build_system", return_value="SYS"), \ - patch.object(reminders.client.messages, "create", - side_effect=RuntimeError("boom")): - out = reminders._personalize_reminder("+15550001111", "dentist at 3", {}) - assert "dentist at 3" in out, "a failed draft must still deliver the reminder" - - -class TestThePromptDescribesTheProductThatExists: - """SYSTEM_PROMPT is what Palmer paraphrases to a user when he confirms - something. Every claim in it is a promise, and three had gone stale. - - The price rule is the sharpest case: the prompt still said "~15% drops" - long after that bar was deleted for the second time and replaced with a - flat $2 in either direction. So Palmer described a drop-only percentage - watch and then sent a $2 rise alert. Nothing cross-checked the prose - against the constant, which is why it could drift at all. - """ - - def test_the_price_bar_is_the_one_the_code_uses(self): - from palmer import shopping - block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] - assert f"${shopping.MOVE_MIN_ABS:.0f}" in block - - def test_the_prompt_never_promises_a_percentage_bar(self): - """A percentage always encodes an assumption about the kind of product, - and the watch list holds every kind. Two versions failed that way.""" - block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] - assert "%" not in block - - def test_the_price_watch_promise_includes_rises(self): - block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] - assert "rise" in block.lower() - - def test_the_morning_is_described_as_basics_plus_a_link(self): - """It stopped carrying tracked topics two versions ago; the prompt - still promised "sports scores, news, Bitcoin price".""" - block = agent.SYSTEM_PROMPT.split("MORNING BRIEFING")[1].split("PRICE WATCHES")[0] - assert "page" in block - assert "commute" in block.lower() - - def test_nothing_claims_flight_watching_is_missing(self): - """add_flight_watch shipped, and one NEVER bullet still held up "I - can't watch them for changes yet" as the model sentence to imitate — - against the routing block's own "Never say you can't track flights".""" - assert "can't watch them for changes" not in agent.SYSTEM_PROMPT - - -class TestEveryToolIsRouted: - def test_the_prompt_names_every_tool_it_ships_with(self): - """A tool the routing block never mentions is one the model resolves - from its own description alone — and add_watch's description tells it - to fire on "a team, a story, a market", which is exactly what the - block assigns to update_morning_briefing and follow_team.""" - from palmer.tools_def import TOOLS - missing = [t["name"] for t in TOOLS if t["name"] not in agent.SYSTEM_PROMPT] - assert missing == [], f"tools with no routing guidance: {missing}" - - def test_the_three_way_track_collision_is_resolved(self): - block = agent.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] - line = next(ln for ln in block.split("\n") if ln.startswith("- add_watch vs")) - for other in ("update_morning_briefing", "follow_team"): - assert other in line - - def test_cancelling_is_routed_too(self): - """"Stop tracking the Eagles" matches four tools, and guessing deletes - something they wanted.""" - block = agent.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] - for verb in ("cancel_watch", "cancel_reminders", "unfollow_team", - "unfollow_show", "cancel_price_watch", "cancel_flight_watch"): - assert verb in block - - -class TestFailureHasAPolicyOfItsOwn: - """It used to be a subordinate clause inside the anti-competitor NEVER - bullet — the prompt said at length what Palmer must not say on a failure - and almost nothing about what he should.""" - - def test_there_is_a_section_for_it(self): - assert "WHEN A TOOL COMES BACK EMPTY OR BROKEN" in agent.SYSTEM_PROMPT - - def test_it_separates_empty_from_broken_from_incapable(self): - block = agent.SYSTEM_PROMPT.split("WHEN A TOOL COMES BACK EMPTY OR BROKEN")[1] \ - .split("CURATION")[0] - assert "Empty is not broken" in block - assert "invent" in block - assert "somewhere else" in block - - def test_a_failed_lookup_is_not_a_fact_about_the_world(self): - """The failed SpaceX lookup confirmed the model's own stale prior and - Palmer told the user the company was private while SPCX was trading.""" - block = agent.SYSTEM_PROMPT.split("WHEN A TOOL COMES BACK EMPTY OR BROKEN")[1] \ - .split("CURATION")[0] - assert "not a company that isn't listed" in block - - -class TestClarificationOutranksTheRhythmRules: - """The rule permitting a clarifying question was stated once. The pressure - against ending on a question was stated four times, three of them - absolute — including one that is shape-based and unconditional, and so - fires exactly when a clarification needs a second turn.""" - - def test_the_precedence_is_stated(self): - block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ - .split("READ THE SUBTEXT")[0] - assert "outranks" in block - - def test_asking_twice_is_allowed_when_it_is_still_unclear(self): - block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ - .split("READ THE SUBTEXT")[0] - assert "ask again" in block - - def test_a_resolved_thing_is_named_back_not_confirmed_silently(self): - block = agent.SYSTEM_PROMPT.split("WHEN YOU DON'T KNOW WHAT THEY MEAN")[1] \ - .split("READ THE SUBTEXT")[0] - assert "resolved" in block - assert "correct it" in block diff --git a/tests/test_cards.py b/tests/test_cards.py deleted file mode 100644 index 8504075..0000000 --- a/tests/test_cards.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Tests for the dashboard renderer and the structured snapshots behind it. - -The card is enrichment layered on top of a briefing that must still arrive if -rendering fails, so most of these assert on graceful degradation rather than on -pixels. Visual quality is judged by looking at the output, not here. -""" -from unittest.mock import patch, MagicMock - -from PIL import Image -import io - -from palmer import cards -from palmer import datafeeds -from palmer import traffic as traffic_mod -from palmer import weather as weather_mod - -WEATHER = {"resolved": "Kirkwood, Missouri", "temp_now": 81.0, "feels_like": 84.0, - "humidity": 55, "wind": 7.0, "weather_code": 3, "description": "Overcast", - "high": 83.0, "low": 64.0, "rain_pct": 20, "gusts": None} -TRAFFIC = {"live_min": 17, "free_min": 16, "delay_min": 0, "miles": 13.7, "ratio": 1.024} -PRICES = [{"label": "Bitcoin", "price": 77752.0, "pct_24h": 1.1, "pct_7d": 0.4, - "series": [76000, 76500, 77000, 77752], "is_crypto": True}] -HEADS = ["Cards blown out 12-3", "SpaceX hits 100 launches"] -OPENING = [{"kind": "event", "title": "Todd Rundgren", "when": "Friday", "source": "t.com"}, - {"kind": "event", "title": "The Wallflowers", "when": "Saturday", "source": "t.com"}, - {"kind": "screen", "title": "Colony", "when": "in theaters", "source": "tmdb.org"}] - - -def _render(**kw): - args = dict(city="Kirkwood, MO", weather=WEATHER, traffic=TRAFFIC, - prices=PRICES, headlines=HEADS) - args.update(kw) - return cards.render_dashboard(**args) - - -class TestRender: - def test_produces_a_png_at_og_spec(self): - """1200x630 so one asset serves both the MMS card and the og:image.""" - img = Image.open(io.BytesIO(_render())) - assert img.format == "PNG" - assert img.size == (cards.W, cards.H) == (1200, 630) - - def test_not_blank(self): - img = Image.open(io.BytesIO(_render())).convert("RGB") - assert len(img.getcolors(maxcolors=1_000_000) or []) > 50, "render looks empty" - - def test_under_mms_size_limit(self): - assert len(_render()) < 5_000_000 - - def test_deterministic(self): - from datetime import datetime - when = datetime(2026, 8, 23, 7, 15) - assert _render(when=when) == _render(when=when) - - -class TestDegradesSection_by_section: - """A missing snapshot drops its panel; it must never fail the render, because - a briefing without a card is fine and a briefing that doesn't send is not.""" - - def test_each_section_may_be_absent(self): - for missing in ("weather", "traffic", "prices", "headlines"): - assert _render(**{missing: None}) - - def test_everything_absent(self): - assert _render(weather=None, traffic=None, prices=None, headlines=None) - - def test_partial_weather(self): - assert _render(weather={"description": "Overcast"}) - - def test_price_without_series(self): - assert _render(prices=[{"label": "X", "price": 1.5, "pct_24h": -2.0, "series": []}]) - - def test_long_headline_is_clipped_not_crashed(self): - assert _render(headlines=["x" * 400]) - - def test_missing_city(self): - assert _render(city="") - - -class TestOpeningBand: - """Opening draws in the left column between the weather chips and the news - rule — the one band of the card that was empty.""" - - def test_the_card_renders_with_opening(self): - img = Image.open(io.BytesIO(_render(opening=OPENING))) - assert img.size == (cards.W, cards.H) - - def test_it_changes_the_pixels(self): - """A section that draws nothing is a section that isn't there.""" - assert _render(opening=OPENING) != _render(opening=None) - - def test_absent_opening_is_fine(self): - assert _render(opening=None) and _render(opening=[]) - - def test_more_rows_than_fit_do_not_overflow_into_the_news_band(self): - many = [{"kind": "event", "title": f"Act number {i}", "when": "Friday"} - for i in range(9)] - a = _render(opening=many) - b = _render(opening=many[:cards.CARD_OPENING_ROWS]) - assert a == b, "rows past the cap must not be drawn at all" - - def test_a_very_long_title_is_clipped_rather_than_running_under_markets(self): - long = [{"kind": "local", "title": "A restaurant with an absurdly long name " * 4, - "when": "Friday"}] - assert _render(opening=long) - - -class TestCardCacheKey: - """The card image is memoised. It used to key on `built_at`, which only - advances inside home.rebuild() — and ensure_fresh calls rebuild only when - there is no payload at all. So after a user's first build the key never - changed again and the card froze on that morning's weather while the page - beside it stayed live.""" - - PAYLOAD = {"city": "Kirkwood, MO", "weather": WEATHER, "traffic": TRAFFIC, - "prices": PRICES, "opening": OPENING, - "headlines": [{"title": h} for h in HEADS], "built_at": 1} - - def _fp(self, **over): - from palmer import artifacts - return artifacts._card_fingerprint(dict(self.PAYLOAD, **over)) - - def test_identical_content_is_a_cache_hit(self): - assert self._fp() == self._fp() - - def test_a_stale_built_at_no_longer_freezes_the_card(self): - """Same built_at, different weather — the old key could not tell these - apart, which is the whole bug.""" - warmer = dict(WEATHER, temp_now=42.0) - assert self._fp(weather=warmer) != self._fp() - - def test_every_drawn_section_moves_the_key(self): - for field, value in (("city", "Denver, CO"), - ("traffic", dict(TRAFFIC, live_min=99)), - ("prices", []), - ("opening", []), - ("headlines", [{"title": "something else"}])): - assert self._fp(**{field: value}) != self._fp(), f"{field} must re-key" - - def test_something_not_drawn_does_not_move_the_key(self): - """tracking and the token never reach the renderer, so they must not - cost a re-render.""" - assert self._fp(tracking={"topics": ["new"]}) == self._fp() - - def test_render_png_reuses_the_image_for_identical_content(self): - from palmer import artifacts - artifacts._png_cache.clear() - a = artifacts.render_png("tok", dict(self.PAYLOAD)) - b = artifacts.render_png("tok", dict(self.PAYLOAD)) - assert a is b - - def test_render_png_redraws_when_the_content_moves(self): - from palmer import artifacts - artifacts._png_cache.clear() - a = artifacts.render_png("tok", dict(self.PAYLOAD)) - b = artifacts.render_png("tok", dict(self.PAYLOAD, weather=dict(WEATHER, temp_now=12.0))) - assert a != b - - def test_opening_reaches_the_renderer(self): - from palmer import artifacts - assert "opening" in artifacts._card_inputs(self.PAYLOAD) - - -class TestMeter: - def _ratio_colour(self, ratio): - img = Image.new("RGBA", (200, 60), (0, 0, 0, 255)) - return cards._meter(img, 10, 20, 160, ratio) - - def test_free_flowing_is_green(self): - assert self._ratio_colour(1.02) == cards.UP - - def test_moderate_is_amber(self): - assert self._ratio_colour(1.25) == cards.WARM - - def test_heavy_is_red(self): - assert self._ratio_colour(1.5) == cards.DOWN - - def test_extreme_ratio_clamps(self): - assert self._ratio_colour(9.0) == cards.DOWN - - -class TestSparkline: - def test_too_few_points_is_a_noop(self): - img = Image.new("RGB", (100, 40)) - before = img.tobytes() - cards._sparkline(cards.ImageDraw.Draw(img), 0, 0, 90, 30, [1.0], cards.UP) - assert img.tobytes() == before - - def test_flat_series_does_not_divide_by_zero(self): - img = Image.new("RGB", (100, 40)) - cards._sparkline(cards.ImageDraw.Draw(img), 0, 0, 90, 30, [5.0, 5.0, 5.0], cards.UP) - - -class TestFontResolution: - def test_returns_a_font(self): - assert cards._font(24) is not None - - def test_falls_back_when_no_font_dir_exists(self): - cards._font_cache.clear() - with patch.object(cards, "_FONT_DIRS", ("/nonexistent",)): - assert cards._font(24, True) is not None - cards._font_cache.clear() - - -class TestSnapshots: - """Structured returns are additive — the prose paths must be untouched.""" - - def test_weather_snapshot_shape(self): - """The Open-Meteo branch, reached via non-US coords — US locations go to - NWS now (see test_weather_source.py). Patching _fetch_openmeteo alone is - not enough to keep this offline: with US coords it would route to NWS and - make a real call.""" - payload = {"current": {"temperature_2m": 81.0, "apparent_temperature": 84.0, - "weather_code": 3, "wind_speed_10m": 7.0, - "relative_humidity_2m": 55}, - "daily": {"temperature_2m_max": [83.0], "temperature_2m_min": [64.0], - "precipitation_probability_max": [20], - "wind_gusts_10m_max": [12.0]}} - with patch.object(weather_mod, "_geocode", return_value=(48.86, 2.35, "Paris")), \ - patch.object(weather_mod, "_fetch_openmeteo", return_value=payload): - s = weather_mod.weather_snapshot("Paris") - assert s["temp_now"] == 81.0 and s["high"] == 83.0 and s["weather_code"] == 3 - assert s["description"] - - def test_weather_snapshot_returns_none_on_failure(self): - with patch.object(weather_mod, "_geocode", side_effect=RuntimeError("boom")): - assert weather_mod.weather_snapshot("Nowhere") is None - - def test_traffic_snapshot_ratio(self): - payload = {"routes": [{"summary": {"travelTimeInSeconds": 1020, - "noTrafficTravelTimeInSeconds": 960, - "trafficDelayInSeconds": 60, - "lengthInMeters": 22000}}]} - with patch.object(traffic_mod, "_geocode_address", return_value=(38.5, -90.4)), \ - patch.object(traffic_mod, "_http_get_json", return_value=payload): - s = traffic_mod.traffic_snapshot("a street", "b street") - assert s["live_min"] == 17 and s["free_min"] == 16 - assert round(s["ratio"], 2) == 1.06, "ratio is what the meter renders" - - def test_traffic_snapshot_none_on_bad_route(self): - with patch.object(traffic_mod, "_geocode_address", return_value=(1, 2)), \ - patch.object(traffic_mod, "_http_get_json", return_value={"routes": []}): - assert traffic_mod.traffic_snapshot("a", "b") is None - - def test_price_snapshot_crypto(self): - resp = MagicMock() - resp.json.return_value = {"bitcoin": {"usd": 77752.0, "usd_24h_change": 1.1, - "usd_7d_change": 0.4}} - resp.raise_for_status = lambda: None - chart = MagicMock() - chart.json.return_value = {"prices": [[0, 76000], [1, 77752]]} - chart.raise_for_status = lambda: None - with patch.object(datafeeds, "_requests") as rq: - rq.get.side_effect = [resp, chart] - s = datafeeds.price_snapshot("bitcoin") - assert s["price"] == 77752.0 and s["is_crypto"] is True - assert len(s["series"]) == 2 - - def test_price_snapshot_crypto_symbol_is_the_real_coingecko_id(self): - """`asset` here is the alias a topic matched on ("avax"), not the - coingecko id. The page builds coingecko.com links from `symbol`, so it - must carry the resolved id ("avalanche-2"), not the alias — a link - built from the alias 404s.""" - resp = MagicMock() - resp.json.return_value = {"avalanche-2": {"usd": 20.0, "usd_24h_change": 1.0}} - resp.raise_for_status = lambda: None - with patch.object(datafeeds, "_requests") as rq: - rq.get.side_effect = [resp, RuntimeError("chart down")] - s = datafeeds.price_snapshot("avax", "Avalanche") - assert s["symbol"] == "avalanche-2" - - def test_price_snapshot_stock_symbol_is_the_real_ticker(self): - """An index's `label` is a human string ("S&P 500"); `symbol` must stay - the actual Yahoo ticker ("^GSPC") so the page's link works.""" - ticker = MagicMock() - ticker.fast_info.last_price = 5000.0 - ticker.history.return_value.empty = True - with patch("yfinance.Ticker", return_value=ticker): - s = datafeeds.price_snapshot("^GSPC", "S&P 500") - assert s["symbol"] == "^GSPC" and s["label"] == "S&P 500" - - def test_price_snapshot_survives_missing_sparkline(self): - resp = MagicMock() - resp.json.return_value = {"bitcoin": {"usd": 100.0, "usd_24h_change": 0.0}} - resp.raise_for_status = lambda: None - with patch.object(datafeeds, "_requests") as rq: - rq.get.side_effect = [resp, RuntimeError("chart down")] - s = datafeeds.price_snapshot("bitcoin") - assert s["price"] == 100.0 and s["series"] == [], "sparkline is a bonus, not a dependency" - - def test_price_snapshot_none_on_failure(self): - with patch.object(datafeeds, "_requests") as rq: - rq.get.side_effect = RuntimeError("boom") - assert datafeeds.price_snapshot("bitcoin") is None diff --git a/tests/test_clock_correctness.py b/tests/test_clock_correctness.py deleted file mode 100644 index e0f1503..0000000 --- a/tests/test_clock_correctness.py +++ /dev/null @@ -1,178 +0,0 @@ -"""The remaining places a date was computed on the wrong calendar. - -Each of these answered confidently and was wrong by a day, which is worse than -failing: the output names the date, so the user reads a specific claim that -does not match reality. -""" -import importlib -from datetime import date, datetime, timezone -from unittest.mock import patch - - -from palmer import datafeeds -from palmer import timeutil -from palmer import userprofile -from palmer import weather - - -class TestTheMarketDayIsNewYorks: - def test_the_exchange_timezone_is_pinned(self): - """Not the server's day, and deliberately not the reader's either — a - session closes when New York says it does, whoever is asking.""" - assert datafeeds._MARKET_TZ == "America/New_York" - - def test_after_the_utc_rollover_it_is_still_the_same_trading_day(self): - # 00:30Z on Aug 31 is 20:30 ET on Aug 30 — the afternoon's close is - # TODAY's, and the old date.today() labelled it "yesterday". - instant = datetime(2026, 8, 31, 0, 30, tzinfo=timezone.utc) - with patch("palmer.timeutil.datetime") as dt: - dt.now.side_effect = lambda tz=None: instant.astimezone(tz) if tz else instant - assert timeutil.local_today(datafeeds._MARKET_TZ) == date(2026, 8, 30) - assert timeutil.local_today(None) == date(2026, 8, 31) - - -class TestWeatherDayResolution: - """The convention lives in timeutil now, not weather. - - It is generic date reasoning, and leaving it in the weather module was - how the reminder path — the one place the model computes a date itself — - ended up with no answer for "next friday" at all, while weather had a - considered one.""" - - def _delta(self, when, on): - with patch.object(timeutil, "local_today", return_value=on): - return timeutil.resolve_day_delta(when, when.lower()) - - FRIDAY = date(2026, 8, 28) - WEDNESDAY = date(2026, 8, 26) - - def test_tomorrow_is_one_day(self): - assert self._delta("tomorrow", self.FRIDAY) == 1 - - def test_a_bare_weekday_is_the_next_one(self): - assert self._delta("friday", self.WEDNESDAY) == 2 - - def test_next_weekday_is_a_week_past_that(self): - """"friday" and "next friday" used to be indistinguishable, so someone - planning a week out got this week's forecast under next week's name.""" - assert self._delta("next friday", self.WEDNESDAY) == 9 - - def test_the_two_rules_compose_rather_than_stacking(self): - """A bare weekday naming TODAY resolves a week out (an existing, tested - decision), so a flat +7 on top would put "next friday" a fortnight away.""" - assert self._delta("friday", self.FRIDAY) == 7 - assert self._delta("next friday", self.FRIDAY) == 7 - - def test_an_explicit_date_still_wins(self): - assert self._delta("2026-08-30", self.FRIDAY) == 2 - - def test_a_misspelt_weekday_still_resolves(self): - """Substring matching is doing real work here: "thursdayish" is still a - Thursday, and "next" still bumps it.""" - assert self._delta("next thursdayish", self.FRIDAY) == 13 - - def test_unparseable_input_returns_none(self): - """The caller then answers for TODAY and logs it. It used to silently - become tomorrow, so an unreadable phrase was answered for a day the user - never named — and the output states that date as fact.""" - for phrase in ("sometime soon", "when it cools off", "later"): - assert self._delta(phrase, self.FRIDAY) is None, phrase - - def test_the_callers_default_to_today_not_tomorrow(self): - import inspect - src = inspect.getsource(weather) - assert "delta = 1" not in src, "unparseable input must not mean tomorrow" - - def test_the_target_is_anchored_on_the_readers_calendar(self): - import inspect - src = inspect.getsource(weather._nws_report) - # delta is computed in the user's zone, so it must be added to the - # user's today — not to the forecast grid's, which is a different place. - assert "_lt(tz) if valid_zone(tz)" in src - - -class TestTimezoneIsValidatedAndRepaired: - def _apply(self, profile, updates, derived=None): - with patch.object(userprofile, "upsert_profile") as up, \ - patch.object(userprofile, "get_profile", return_value=profile), \ - patch.object(userprofile, "_derive_timezone", return_value=derived), \ - patch.object(userprofile, "_eager_build_home"): - userprofile._apply_profile_updates("+15550001111", profile, updates) - return up.call_args[0][1] if up.call_args else {} - - def test_junk_from_the_extractor_is_dropped(self): - """`timezone` is in EXTRACT_PROMPT's schema, so Haiku can write anything. - An unresolvable value degrades every local_now call to UTC, silently.""" - written = self._apply({"city": "Chicago"}, {"timezone": "Pacific Time"}) - assert "timezone" not in written - - def test_a_real_zone_is_kept(self): - written = self._apply({"city": "Chicago"}, {"timezone": "America/Denver"}) - assert written["timezone"] == "America/Denver" - - def test_a_move_re_derives_the_zone(self): - """It used to derive only when ABSENT, so someone who moved kept the old - zone forever and their morning arrived at the wrong hour from then on.""" - written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, - {"city": "Los Angeles"}, - derived="America/Los_Angeles") - assert written["timezone"] == "America/Los_Angeles" - - def test_no_move_leaves_it_alone(self): - written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, - {"vibe": "cheerful"}, derived="America/Denver") - assert "timezone" not in written - - def test_an_explicit_timezone_update_is_not_overridden_by_the_city(self): - written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, - {"city": "Denver", "timezone": "America/Denver"}, - derived="America/Boise") - assert written["timezone"] == "America/Denver" - - def test_the_forecast_correction_path_does_not_reach_this_function(self): - """CLAUDE.md: correcting a weather city must not move the hour the - morning arrives. That write calls upsert_profile directly.""" - import inspect - from palmer import agent - src = inspect.getsource(agent.get_reply) - assert "_city_from_weather_topic" in src - assert "_apply_profile_updates" not in src - - -class TestConsolidationDoesNotRunEveryTurn: - def test_the_gate_exists(self): - assert userprofile.CONSOLIDATE_EVERY >= 10 - - def test_it_is_skipped_until_enough_new_messages(self): - with patch.object(userprofile, "get_message_count", return_value=45), \ - patch.object(userprofile, "get_profile", - return_value={"consolidated_at_count": 40}), \ - patch.object(userprofile, "get_older_messages") as older: - userprofile._consolidate_history("+15550001111") - older.assert_not_called() - - def test_it_runs_once_the_gap_is_wide_enough(self): - with patch.object(userprofile, "get_message_count", return_value=65), \ - patch.object(userprofile, "get_profile", - return_value={"consolidated_at_count": 40}), \ - patch.object(userprofile, "get_older_messages", return_value=[]) as older: - userprofile._consolidate_history("+15550001111") - older.assert_called_once() - - def test_the_watermark_is_bookkeeping_not_an_extraction_field(self): - from palmer import prompts - assert "consolidated_at_count" in userprofile.PROFILE_FIELDS - assert "consolidated_at_count" not in prompts.EXTRACT_PROMPT - - -class TestCurationIsToldTheReadersDate: - def test_curate_takes_a_date(self): - import inspect - sig = inspect.signature(importlib.import_module("palmer.opening")._curate) - assert "today" in sig.parameters - - def test_the_snapshot_passes_the_local_day(self): - import inspect - from palmer import opening - src = inspect.getsource(opening.opening_snapshot) - assert "_curate(metro or _metro(city), pool, today=today)" in src diff --git a/tests/test_commute.py b/tests/test_commute.py deleted file mode 100644 index 24347be..0000000 --- a/tests/test_commute.py +++ /dev/null @@ -1,403 +0,0 @@ -"""The commute is routed for the leave time, not for the moment of the fetch. - -The morning job runs at the user's morning time and the page refreshes on tap, -so a user who leaves at 8:30 used to be told the 7:00 number. Now a commute is -saved by a tool (set_commute) that geocodes on the write path and may carry a -leave time; the fetch routes for that departure when it is still ahead -(TomTom departAt) and live otherwise, and every surface says which one it got. -The page never renders the addresses — it is a tokenized URL and those are -someone's home and office. -""" -import inspect -import re -from datetime import datetime, timedelta -from unittest.mock import patch -from zoneinfo import ZoneInfo - -from palmer import agent -from palmer import artifacts -from palmer import home -from palmer import morning -from palmer import page -from palmer import prompts -from palmer import traffic -from palmer import userprofile -from palmer.timeutil import friendly_hhmm -from palmer.tools_def import TOOLS - -CHI = ZoneInfo("America/Chicago") -ROUTE = {"routes": [{"summary": {"travelTimeInSeconds": 1020, - "noTrafficTravelTimeInSeconds": 960, - "trafficDelayInSeconds": 60, - "lengthInMeters": 22000}}]} -ORIGIN = "33 Cedarbrook Lane, Kirkwood MO 63122" -DEST = "1 Market St, St. Louis MO 63102" - - -class TestFriendlyTime: - def test_morning_afternoon_and_the_two_twelves(self): - assert friendly_hhmm("08:30") == "8:30am" - assert friendly_hhmm("17:05") == "5:05pm" - assert friendly_hhmm("12:00") == "12:00pm" - assert friendly_hhmm("00:05") == "12:05am" - - def test_never_raises_on_a_render_path(self): - assert friendly_hhmm("") == "" - assert friendly_hhmm(None) == "" - assert friendly_hhmm("noon") == "noon" - assert friendly_hhmm("25:00") == "25:00" - - -# What TomTom actually returns for a departAt route: the trip is a third -# slower than free-flow and trafficDelayInSeconds is 0, because that field -# counts live incidents and a prediction has none. -PREDICTED_ROUTE = {"routes": [{"summary": {"travelTimeInSeconds": 1740, - "noTrafficTravelTimeInSeconds": 1320, - "trafficDelayInSeconds": 0, - "lengthInMeters": 22000}}]} - - -def _snapshot(route=ROUTE, **kw): - """traffic_snapshot against a canned TomTom reply; returns (result, url).""" - seen = {} - - def _get(url, **_): - seen["url"] = url - return route - - with patch.object(traffic, "_http_get_json", side_effect=_get): - out = traffic.traffic_snapshot(ORIGIN, DEST, **kw) - return out, seen.get("url", "") - - -class TestSnapshotDeparture: - def setup_method(self): - traffic._addr_geo_cache.clear() - - def test_a_future_departure_is_routed_for_and_labelled(self): - depart = datetime.now(CHI) + timedelta(hours=2) - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, url = _snapshot(depart_at=depart, tz_name="America/Chicago") - assert "departAt=" in url - assert out["predicted"] is True - assert out["depart_at"] == depart.strftime("%H:%M") - assert out["arrive_at"] == (depart + timedelta(seconds=1020)).strftime("%H:%M") - - def test_the_stamp_is_percent_encoded(self): - """An offset east of UTC carries a "+", which decodes to a space in a - query string and TomTom 400s on it.""" - depart = datetime.now(ZoneInfo("Europe/Paris")) + timedelta(hours=2) - with patch.object(traffic, "_geocode_address", return_value=(48.8, 2.3)): - _, url = _snapshot(depart_at=depart, tz_name="Europe/Paris") - stamp = url.split("departAt=")[1] - assert "+" not in stamp and "%2B" in stamp and "%3A" in stamp - - def test_a_predicted_delay_is_live_minus_free_flow(self): - depart = datetime.now(CHI) + timedelta(hours=2) - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, _ = _snapshot(route=PREDICTED_ROUTE, depart_at=depart, tz_name="America/Chicago") - assert out["live_min"] == 29 and out["free_min"] == 22 - assert out["delay_min"] == 7, "a 29-vs-22 prediction must not read as 'normal'" - # A live route keeps TomTom's incident delay, unchanged from before. - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, _ = _snapshot(tz_name="America/Chicago") - assert out["delay_min"] == 1 - - def test_no_zone_means_no_arrival_clock(self): - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, _ = _snapshot(tz_name=None) - bad, _ = _snapshot(tz_name="Pacific Time") - ok, _ = _snapshot(tz_name="America/Chicago") - assert out["arrive_at"] is None and bad["arrive_at"] is None - assert ok["arrive_at"] and out["live_min"] == ok["live_min"] - - def test_a_past_departure_routes_live(self): - depart = datetime.now(CHI) - timedelta(minutes=10) - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, url = _snapshot(depart_at=depart, tz_name="America/Chicago") - assert "departAt" not in url - assert out["predicted"] is False and "depart_at" not in out - assert re.fullmatch(r"\d\d:\d\d", out["arrive_at"]) - - def test_a_naive_departure_is_ignored_rather_than_read_as_utc(self): - depart = datetime.now() + timedelta(hours=2) - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, url = _snapshot(depart_at=depart) - assert "departAt" not in url and out["predicted"] is False - - def test_no_departure_is_the_old_behaviour_plus_a_label(self): - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, url = _snapshot() - assert "departAt" not in url - assert out["live_min"] == 17 and out["free_min"] == 16 and out["delay_min"] == 1 - assert round(out["ratio"], 2) == 1.06 and out["miles"] == 13.7 - assert out["predicted"] is False - - def test_stored_coordinates_skip_the_geocoder(self): - with patch.object(traffic, "_geocode_address", side_effect=AssertionError("geocoded")): - out, url = _snapshot(origin_ll=[38.5, -90.4], dest_ll=[38.6, -90.2]) - assert "38.5,-90.4:38.6,-90.2" in url and out["live_min"] == 17 - - def test_a_legacy_string_commute_still_geocodes(self): - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)) as geo: - out, _ = _snapshot() - assert geo.call_count == 2 and out is not None - - def test_the_result_never_carries_the_addresses(self): - """The page renders whatever this returns, and the page has no auth.""" - with patch.object(traffic, "_geocode_address", return_value=(38.5, -90.4)): - out, _ = _snapshot(depart_at=datetime.now(CHI) + timedelta(hours=1)) - assert "origin" not in out and "destination" not in out - assert not any(ORIGIN in str(v) or DEST in str(v) for v in out.values()) - - -class TestAddressCache: - def setup_method(self): - traffic._addr_geo_cache.clear() - - def test_second_lookup_costs_nothing(self): - reply = {"results": [{"position": {"lat": 1.0, "lon": 2.0}}]} - with patch.object(traffic, "_http_get_json", return_value=reply) as http: - assert traffic._geocode_address("1 Main St") == (1.0, 2.0) - assert traffic._geocode_address(" 1 main st ") == (1.0, 2.0) - assert http.call_count == 1 - - def test_a_miss_is_not_cached(self): - with patch.object(traffic, "_http_get_json", return_value={"results": []}) as http: - assert traffic._geocode_address("nowhere") is None - assert traffic._geocode_address("nowhere") is None - assert http.call_count == 2 - - -class TestDepartureRule: - def _at(self, hhmm): - h, m = hhmm.split(":") - return datetime(2026, 9, 2, int(h), int(m), tzinfo=CHI) - - def test_a_leave_time_still_ahead_is_routed_for(self): - out = home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("07:00")) - assert out == self._at("08:30") and out.utcoffset() == timedelta(hours=-5) - - def test_inside_the_lead_or_already_past_routes_live(self): - assert home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("08:28")) is None - assert home._commute_depart_at({"leave_time": "08:30"}, "America/Chicago", now=self._at("09:00")) is None - - def test_no_or_bad_leave_time_routes_live(self): - assert home._commute_depart_at({}, "America/Chicago", now=self._at("07:00")) is None - assert home._commute_depart_at({"leave_time": "soonish"}, "America/Chicago", now=self._at("07:00")) is None - - def test_no_zone_routes_live_rather_than_predicting_for_utc(self): - # Without a resolvable zone "08:30" has no clock to sit on; predicting - # for 08:30Z and labelling it 8:30am would be confidently wrong. - assert home._commute_depart_at({"leave_time": "08:30"}, None) is None - assert home._commute_depart_at({"leave_time": "08:30"}, "Pacific Time") is None - - def test_fetch_forwards_the_stored_route_and_departure(self): - profile = {"timezone": "America/Chicago", - "commute": {"origin": ORIGIN, "destination": DEST, "leave_time": "08:30", - "origin_ll": [38.5, -90.4], "dest_ll": [38.6, -90.2]}} - depart = self._at("08:30") - with patch.object(home, "_commute_depart_at", return_value=depart), \ - patch("palmer.traffic.traffic_snapshot", return_value={"live_min": 3}) as snap: - assert home._fetch_traffic(profile) == {"live_min": 3} - kw = snap.call_args.kwargs - assert kw["depart_at"] == depart and kw["tz_name"] == "America/Chicago" - assert kw["origin_ll"] == [38.5, -90.4] and kw["dest_ll"] == [38.6, -90.2] - - def test_a_legacy_commute_passes_no_coordinates(self): - profile = {"commute": {"origin": ORIGIN, "destination": DEST}} - with patch("palmer.traffic.traffic_snapshot", return_value=None) as snap: - home._fetch_traffic(profile) - kw = snap.call_args.kwargs - assert kw["origin_ll"] is None and kw["dest_ll"] is None and kw["depart_at"] is None - - -class TestDigest: - def test_predicted_names_the_departure_and_arrival(self): - d = morning._payload_digest({"traffic": {"live_min": 34, "delay_min": 9, "predicted": True, - "depart_at": "08:30", "arrive_at": "09:04"}}) - assert "at 8:30am" in d and "predicted for that departure" in d - assert "34 min" in d and "9 min slower than normal" in d - assert "arriving about 9:04am" in d - - def test_live_says_so_and_invents_no_leave_time(self): - d = morning._payload_digest({"traffic": {"live_min": 20, "delay_min": 0, "predicted": False, - "arrive_at": "07:22"}}) - assert d.startswith("Commute right now: 20 min, normal") - assert "arriving about 7:22am" in d and "leave" not in d - - def test_the_drafter_is_told_which_moment_the_number_is_for(self): - src = inspect.getsource(morning.generate_morning_line) - assert "for THAT departure" in src and "don't invent a time" in src - - -class TestPage: - BASE = dict(city="Kirkwood, MO", weather={"temp_now": 71}, prices=[], headlines=[], - fetched={}, tracking={}) - - def _html(self, traffic_dict): - return page.render(dict(self.BASE, traffic=traffic_dict), token="tok", - image_url="https://x/y.png", page_url="https://x/h/tok") - - def test_predicted_card_shows_leave_and_arrive(self): - html = self._html({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, - "depart_at": "08:30", "arrive_at": "09:04"}) - assert "leaves 8:30am" in html and "arrives ~9:04am" in html - assert "34 min commute at 8:30am" in html # og:description - - def test_live_card_says_right_now(self): - html = self._html({"live_min": 17, "delay_min": 0, "ratio": 1.0, "predicted": False, - "arrive_at": "07:19"}) - assert "right now" in html and "leaves" not in html - assert "17 min commute" in html and "commute at" not in html - - def test_addresses_never_reach_the_page(self): - """Structural: the payload's traffic dict is whatever traffic_snapshot - returned, and that never carries the addresses. Belt and braces here.""" - html = self._html({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, - "depart_at": "08:30", "arrive_at": "09:04"}) - assert ORIGIN not in html and DEST not in html and "Cedarbrook" not in html - - -class TestCard: - def _payload(self, traffic_dict): - return {"city": "Kirkwood, MO", "timezone": "America/Chicago", - "weather": {"temp_now": 71, "high": 88, "low": 64, "description": "clear"}, - "traffic": traffic_dict, "prices": [], "headlines": [], "opening": []} - - def test_renders_with_the_departure_line_and_changes_the_fingerprint(self): - predicted = self._payload({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": True, - "depart_at": "08:30", "arrive_at": "09:04"}) - live = self._payload({"live_min": 34, "delay_min": 9, "ratio": 1.36, "predicted": False, - "arrive_at": "09:04"}) - png = artifacts.render_png("tok-commute-test", predicted) - assert png[:8] == b"\x89PNG\r\n\x1a\n" - assert artifacts._card_fingerprint(predicted) != artifacts._card_fingerprint(live) - - -def _tool(name): - return next((t for t in TOOLS if t["name"] == name), None) - - -class TestToolsAndRouting: - def test_both_tools_exist_and_set_takes_an_optional_leave_time(self): - s = _tool("set_commute") - assert s is not None and _tool("clear_commute") is not None - assert set(s["input_schema"]["required"]) == {"origin", "destination"} - assert "leave_time" in s["input_schema"]["properties"] - - def test_set_carries_the_landmark_warning_get_travel_time_has(self): - s, g = _tool("set_commute")["description"], _tool("get_travel_time")["description"] - assert "landmark" in s and "street address" in s - assert "Fenway Park" in s and "Fenway Park" in g - - def test_travel_time_no_longer_disclaims_storing_addresses(self): - g = _tool("get_travel_time")["description"] - assert "don't store" not in g and "set_commute" in g - - def test_system_prompt_routes_the_regular_drive(self): - block = prompts.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] - assert "set_commute" in block and "clear_commute" in block - assert "every day" in block - - def test_commute_is_not_in_the_extractor_schema(self): - assert '"commute"' not in prompts.EXTRACT_PROMPT - - -class _Block: - def __init__(self, **kw): - self.__dict__.update(kw) - - -class _Resp: - def __init__(self, content, stop_reason): - self.content, self.stop_reason = content, stop_reason - - -def _drive(tool_name, tool_input, geocodes=((38.5, -90.4), (38.6, -90.2)), key="k"): - calls = [] - responses = [ - _Resp([_Block(type="tool_use", name=tool_name, id="t1", input=tool_input)], "tool_use"), - _Resp([_Block(type="text", text="done")], "end_turn"), - ] - - def _create(**kw): - calls.append(kw) - return responses[len(calls) - 1] - - with patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "get_profile", return_value={}), \ - patch.object(agent, "upsert_profile") as upsert, \ - patch.object(traffic, "TOMTOM_API_KEY", key), \ - patch.object(traffic, "_geocode_address", side_effect=list(geocodes)), \ - patch("palmer.home.invalidate") as invalidate, \ - patch.object(agent.client.messages, "create", side_effect=_create): - agent.get_reply("+1555", "my commute") - result = calls[1]["messages"][-1]["content"][0]["content"] - saved = upsert.call_args[0][1] if upsert.called else None - return result, saved, invalidate - - -class TestSetDispatch: - def test_geocodes_on_the_write_path_and_expires_the_card(self): - result, saved, invalidate = _drive("set_commute", - {"origin": ORIGIN, "destination": DEST, "leave_time": "8:30"}) - assert saved["commute"] == {"origin": ORIGIN, "destination": DEST, "leave_time": "08:30", - "origin_ll": [38.5, -90.4], "dest_ll": [38.6, -90.2]} - invalidate.assert_called_once_with("+1555", ("traffic",)) - assert "08:30" in result and "without reading the addresses back" in result - - def test_no_leave_time_saves_and_says_the_number_is_live(self): - result, saved, _ = _drive("set_commute", {"origin": ORIGIN, "destination": DEST}) - assert "leave_time" not in saved["commute"] and saved["commute"]["origin_ll"] - assert "live" in result and "optional" in result - - def test_an_unresolvable_address_asks_rather_than_guesses(self): - result, saved, invalidate = _drive("set_commute", {"origin": ORIGIN, "destination": DEST}, - geocodes=((38.5, -90.4), None)) - assert saved is None and "do not guess" in result and DEST in result - invalidate.assert_not_called() - - def test_a_bad_leave_time_saves_nothing(self): - result, saved, _ = _drive("set_commute", - {"origin": ORIGIN, "destination": DEST, "leave_time": "half eight"}) - assert saved is None and "Nothing saved" in result and "HH:MM" in result - - def test_a_missing_key_is_not_reported_as_a_bad_address(self): - result, saved, _ = _drive("set_commute", {"origin": ORIGIN, "destination": DEST}, key="") - assert saved is None and "Nothing saved" in result and "Couldn't find" not in result - - def test_clear_deletes_the_key_and_expires_the_card(self): - result, saved, invalidate = _drive("clear_commute", {}) - assert saved == {"commute": None} - invalidate.assert_called_once_with("+1555", ("traffic",)) - - def test_source_shape(self): - src = inspect.getsource(agent.get_reply) - block = src.split('"set_commute"')[1].split('elif b.name == "clear_commute"')[0] - assert "_geocode_address" in block and "_normalize_hhmm" in block - assert "do not guess" in block and "do not name a maps app" in block - - -class TestExtractorGuard: - def _apply(self, stored, incoming): - profile = {"commute": stored} if stored is not None else {} - with patch.object(userprofile, "upsert_profile") as up, \ - patch.object(userprofile, "get_profile", return_value=profile), \ - patch.object(userprofile, "_derive_timezone", return_value=None): - userprofile._apply_profile_updates("+1555", profile, {"commute": incoming, "job": "x"}) - return up.call_args[0][1] if up.called else {} - - def test_a_tool_written_commute_survives_an_extractor_write(self): - written = self._apply({"origin": "a", "destination": "b", "origin_ll": [1, 2]}, - {"origin": "c", "destination": "d"}) - assert "commute" not in written and written.get("job") == "x" - - def test_a_legacy_commute_may_still_be_replaced(self): - written = self._apply({"origin": "a", "destination": "b"}, {"origin": "c", "destination": "d"}) - assert written.get("commute") == {"origin": "c", "destination": "d"} - - def test_an_absent_commute_is_written(self): - written = self._apply(None, {"origin": "c", "destination": "d"}) - assert written.get("commute") == {"origin": "c", "destination": "d"} diff --git a/tests/test_corroboration.py b/tests/test_corroboration.py deleted file mode 100644 index 2cb06d0..0000000 --- a/tests/test_corroboration.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Tests for the news-quality gate in watches.corroborated(). Every unprompted -news text — a watch alert, a headline on the page — has to clear it.""" -from palmer.watches import corroborated - - -def _r(url: str, score: float = 0.7) -> dict: - return {"url": url, "title": "t", "content": "c", "score": score, - "published_date": "Tue, 18 Aug 2026 12:00:00 GMT"} - - -class TestCorroborated: - def test_two_distinct_domains_pass(self): - results = [_r("https://something.example/a"), _r("https://other.example/b")] - assert corroborated(results) - - def test_single_tier1_passes_alone(self): - # apnews.com is tier 1 in trusted_sources.json - assert corroborated([_r("https://apnews.com/story/xyz")]) - - def test_gov_domain_counts_as_tier1(self): - # .gov is tier-1 via _source_tier - assert corroborated([_r("https://weather.gov/warning/123")]) - - def test_single_unknown_domain_fails(self): - assert not corroborated([_r("https://rumor.example/x")]) - - def test_multiple_urls_same_canonical_domain_fails(self): - results = [ - _r("https://rumor.example/one"), - _r("https://rumor.example/two"), - _r("https://sub.rumor.example/three"), - ] - assert not corroborated(results) - - def test_empty_fails(self): - assert not corroborated([]) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..fcd4868 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,373 @@ +"""The database layer. + +Merged from test_db_claims.py, test_phantom_history.py, test_local_day_guards.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +from datetime import datetime, timedelta, timezone +from palmer import db, morning +from unittest.mock import patch + + +# ============================================================================ +# from test_db_claims.py +# ============================================================================ + +class TestSaveMessageTimestamp: + """Regression test: save_message must write created_at in the same ISO8601+offset + format used for cutoff comparisons, or same-day messages silently fail to match + (SQLite's old CURRENT_TIMESTAMP default sorted before any same-day ISO cutoff).""" + + def test_message_saved_now_matches_a_recent_cutoff(self, fresh_db): + phone = "+15551234567" + db.save_message(phone, "assistant", "hello there") + cutoff = (datetime.now(timezone.utc) - timedelta(hours=6)).isoformat() + assert db.get_recent_assistant_messages(phone, cutoff) == ["hello there"] + + def test_old_message_does_not_match_a_recent_cutoff(self, fresh_db): + phone = "+15551234567" + db.save_message(phone, "assistant", "old message") + cutoff = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + assert db.get_recent_assistant_messages(phone, cutoff) == [] + + +class TestClaimDailyGuard: + def test_first_claim_succeeds(self, fresh_db): + assert db.claim_daily_guard("+15551234567", "morning_sent_date", "2026-08-13") is True + + def test_second_claim_same_value_fails(self, fresh_db): + phone = "+15551234567" + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is False + + def test_claim_different_value_succeeds(self, fresh_db): + phone = "+15551234567" + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-14") is True + + def test_release_allows_reclaim(self, fresh_db): + phone = "+15551234567" + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True + db.upsert_profile(phone, {"morning_sent_date": None}) + assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True + + +class TestClaimWatchAlert: + def test_fresh_row_claims(self, fresh_db): + watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) + assert db.claim_watch_alert(watch_id, 4) is True + + def test_immediate_reclaim_fails(self, fresh_db): + watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) + assert db.claim_watch_alert(watch_id, 4) is True + assert db.claim_watch_alert(watch_id, 4) is False + + +class TestWatchAlertUrlPersistence: + """The 'Watching' page section links a watch to the article that + fired it, so update_watch_alerted must round-trip the URL/domain, not just + the dedup title, and a watch that has never fired must stay linkless.""" + + def test_update_watch_alerted_persists_url_and_domain(self, fresh_db): + phone = "+15551234567" + watch_id = db.save_watch(phone, "test watch", ["test query"], cooldown_hours=4) + db.update_watch_alerted(watch_id, "summary", [], url="https://apnews.com/x", domain="apnews.com") + row = db.get_user_watches(phone)[0] + assert row["last_alert_url"] == "https://apnews.com/x" + assert row["last_alert_domain"] == "apnews.com" + + def test_update_watch_alerted_without_url_leaves_columns_null(self, fresh_db): + phone = "+15551234567" + watch_id = db.save_watch(phone, "test watch", ["test query"], cooldown_hours=4) + db.update_watch_alerted(watch_id, "summary", []) + row = db.get_user_watches(phone)[0] + assert row["last_alert_url"] is None + assert row["last_alert_domain"] is None + + +class TestGetUserPriceWatches: + """last_seen_url/last_seen_merchant already existed on the row and were + already populated by set_price_watch_baseline — they just weren't selected.""" + + def test_baseline_url_and_merchant_round_trip(self, fresh_db): + phone = "+15551234567" + watch_id = db.save_price_watch(phone, "AirPods Pro", target_price=199.0) + db.set_price_watch_baseline(watch_id, 220.0, "https://www.amazon.com/x", "amazon.com") + row = db.get_user_price_watches(phone)[0] + assert row["last_seen_url"] == "https://www.amazon.com/x" + assert row["last_seen_merchant"] == "amazon.com" + + def test_reclaim_after_cooldown_succeeds(self, fresh_db): + watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) + old = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat() + conn = db._conn() + cur = conn.cursor() + cur.execute(f"UPDATE watches SET last_alerted = {db.PH} WHERE id = {db.PH}", (old, watch_id)) + conn.commit() + conn.close() + assert db.claim_watch_alert(watch_id, 4) is True + + +class TestClaimPriceWatchAlert: + def test_fresh_row_claims(self, fresh_db): + watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) + assert db.claim_price_watch_alert(watch_id, 12) is True + + def test_immediate_reclaim_fails(self, fresh_db): + watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) + assert db.claim_price_watch_alert(watch_id, 12) is True + assert db.claim_price_watch_alert(watch_id, 12) is False + + def test_release_allows_reclaim(self, fresh_db): + watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) + assert db.claim_price_watch_alert(watch_id, 12) is True + db.release_price_watch_claim(watch_id) + assert db.claim_price_watch_alert(watch_id, 12) is True + + +class TestPriceWatchRebaseline: + """update_price_watch_alerted must move baseline_price to the alerted price. + + baseline_price is otherwise written exactly once, by set_price_watch_baseline + at watch creation, and never again — so a price that settles below the drop + bar keeps re-qualifying on every tick. The per-day cap bounds that to + PRICE_DAILY_ALERT_MAX texts, then resets the next day, forever. + _is_duplicate_subject can't catch it either: 6h window, 12h cadence.""" + + def _watch(self): + wid = db.save_price_watch("+15551234567", "Core Power Elite Chocolate 12pk") + db.set_price_watch_baseline(wid, 50.98, "https://amazon.com/dp/X", "Amazon") + return wid + + def test_alert_moves_the_baseline(self, fresh_db): + wid = self._watch() + db.update_price_watch_alerted( + wid, 47.98, "https://amazon.com/dp/X", "Amazon", "shake's down to 47.98") + row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] + assert float(row["baseline_price"]) == 47.98 + assert float(row["last_seen_price"]) == 47.98 + + def test_same_price_no_longer_qualifies_after_alert(self, fresh_db): + from palmer.shopping import _should_alert + wid = self._watch() + row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] + assert _should_alert(row, 47.98) == "drop" + + db.update_price_watch_alerted( + wid, 47.98, "https://amazon.com/dp/X", "Amazon", "shake's down to 47.98") + row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] + assert _should_alert(row, 47.98) == "" # already told, don't repeat + assert _should_alert(row, 45.00) == "drop" # a further move still fires + + +# ============================================================================ +# from test_phantom_history.py +# ============================================================================ +# +# What Palmer records must be what Palmer sent. +# +# Four senders got this wrong in two different ways, and both produce the same +# symptom the user sees: Palmer referring to a message that never arrived, or +# repeating one that did. +# +# watches.py (and the since-retired alerts job) called send_sms and ignored the +# result, then saved unconditionally. send_sms returns False on a Twilio failure AND on a +# leaks_deliberation block, so history accumulated messages nobody received — +# and _build_system feeds history straight back to the model. +# +# shopping.py and flightwatch.py never called save_message at all. So a user +# replying "how much?" to a price alert got an answer with no referent, and each +# sender's own _is_duplicate_subject check — which reads assistant messages — +# could never see its own repeats. + +PHONE = "+15550001111" + + +class TestTheKindColumn: + def test_a_kind_round_trips(self, fresh_db): + db.save_message(PHONE, "assistant", "morning line", kind="morning") + conn = db._conn() + cur = conn.cursor() + cur.execute(f"SELECT kind FROM messages WHERE phone = {db.PH}", (PHONE,)) + assert cur.fetchone()["kind"] == "morning" + conn.close() + + def test_kind_is_optional_so_existing_callers_are_unaffected(self, fresh_db): + db.save_message(PHONE, "user", "hey") + assert db.get_history(PHONE) == [{"role": "user", "content": "hey"}] + + def test_migration_is_idempotent(self, fresh_db): + db.save_message(PHONE, "assistant", "x", kind="alert") + assert len(db.get_history(PHONE)) == 1 + + +class TestPriceAlertsReachHistory: + """run_price_watches imports its db helpers inside the function, so they are + patched at their source rather than on the shopping module.""" + + def _run(self, fresh_db, sent: bool): + from palmer import shopping + db.save_price_watch(PHONE, "Nike Pegasus 40", None) + w = dict(db.get_user_price_watches(PHONE)[0]) + w["baseline_price"] = 120.0 + w["cooldown_hours"] = 12 + w["phone"] = PHONE # get_user_price_watches scopes by phone and omits it + with patch.object(db, "get_active_price_watches", return_value=[w]), \ + patch.object(db, "claim_price_watch_alert", return_value=True), \ + patch.object(db, "update_price_watch_alerted"), \ + patch.object(db, "release_price_watch_claim"), \ + patch.object(shopping, "check_price", return_value={ + "price": 90.0, "url": "https://m.example/x", "merchant": "Nike"}), \ + patch.object(shopping, "_draft_alert", return_value="pegasus dropped to $90"), \ + patch("palmer.userprofile._is_duplicate_subject", return_value=False), \ + patch("palmer.sms_util.send_sms", return_value=sent): + shopping.run_price_watches() + return [m for m in db.get_history(PHONE) if m["role"] == "assistant"] + + def test_a_sent_alert_is_recorded(self, fresh_db): + rows = self._run(fresh_db, sent=True) + assert any("pegasus" in m["content"].lower() for m in rows), rows + + def test_a_failed_price_alert_is_not_recorded(self, fresh_db): + assert self._run(fresh_db, sent=False) == [] + + def test_the_alert_is_visible_to_the_next_duplicate_check(self, fresh_db): + """The point of recording it: _is_duplicate_subject reads assistant + messages, so before this the sender could never see its own repeats.""" + from datetime import datetime, timedelta, timezone + self._run(fresh_db, sent=True) + cutoff = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + assert db.get_recent_assistant_messages(PHONE, cutoff) + + +class TestProactiveSendersNeverShipTheFallbackString: + def test_no_proactive_sender_calls_ensure_sms(self): + """ensure_sms's last resort is FALLBACK_SMS. That is right for a reply + the user is waiting on and wrong for anything unprompted — a failed + price check texted 'something went sideways on my end, try again' to + someone who had asked for nothing.""" + import pathlib + import palmer + pkg = pathlib.Path(palmer.__file__).parent + proactive = ["watches.py", "followup.py", "morning.py", "scorewatch.py", + "shopping.py", "flightwatch.py", "reminders.py"] + for name in proactive: + src = (pkg / name).read_text() + calls = [ln for ln in src.splitlines() + if "ensure_sms(" in ln and not ln.strip().startswith("#")] + assert not calls, (name, calls) + + +# ============================================================================ +# from test_local_day_guards.py +# ============================================================================ +# +# A "once a day" guard must mean the reader's day. +# +# (The daily news-alert job that first got this wrong — UTC-keyed guard, local +# send window, two "daily" alerts in one Pacific day — has since been retired; +# its lesson lives on in every remaining daily sender keying on local_today.) +# +# morning._recent_assistant_texts took the last 4 assistant messages of any +# kind, so for anyone who actually texts Palmer the anti-repetition guard was +# comparing today's morning line against ordinary chat rather than against +# yesterday's morning — which is the failure it was written for. + +class TestTheMorningComparesAgainstMornings: + def test_chat_replies_do_not_crowd_out_prior_mornings(self, fresh_db): + db.save_message(PHONE, "assistant", "yesterday's morning line", kind="morning") + for i in range(10): + db.save_message(PHONE, "assistant", f"chat reply {i}", kind="reply") + got = morning._recent_assistant_texts(PHONE, n=4) + assert got == ["yesterday's morning line"], got + + def test_it_falls_back_for_history_predating_the_kind_column(self, fresh_db): + db.save_message(PHONE, "assistant", "an old untagged message") + assert morning._recent_assistant_texts(PHONE, n=4) == ["an old untagged message"] + + def test_ordering_is_oldest_first(self, fresh_db): + for i in range(3): + db.save_message(PHONE, "assistant", f"morning {i}", kind="morning") + assert morning._recent_assistant_texts(PHONE, n=3) == \ + ["morning 0", "morning 1", "morning 2"] + + def test_the_repetition_guard_now_sees_yesterday(self, fresh_db): + """guards.repeats_opening exists for three consecutive mornings that all + opened the same way; it could not see them past a chatty user.""" + from palmer import guards + db.save_message(PHONE, "assistant", "103 today in Woodland Hills, stay inside", + kind="morning") + db.save_message(PHONE, "assistant", "sure, on it", kind="reply") + recent = morning._recent_assistant_texts(PHONE, n=4) + assert guards.repeats_opening("106 today in Woodland Hills, brutal again", recent) + + +class TestTheAlertCapsUseTheReadersDay: + """The retired daily-alert job was fixed for this and the two watch caps were not. + + A cap keyed on the dyno's UTC date rolls at 17:00 Pacific — inside the + evening, not between days — so a user could take the whole day's + allowance across one local evening and be capped by lunch the next day. + The read (_daily_ok) and the write (update_*_alerted) agreed with each + other and both disagreed with the reader. + """ + + def test_a_news_watch_cap_reads_the_day_it_is_given(self): + from palmer import watches + watch = {"daily_alert_date": "2026-08-31", "daily_alert_count": 99} + # Their day is still the 30th while the server has rolled to the 31st. + assert watches._daily_ok(watch, cap=1, today="2026-08-30") is True + assert watches._daily_ok(watch, cap=1, today="2026-08-31") is False + + def test_a_price_watch_cap_reads_the_day_it_is_given(self): + from palmer import shopping + watch = {"daily_alert_date": "2026-08-31", + "daily_alert_count": shopping.PRICE_DAILY_ALERT_MAX} + assert shopping._daily_ok(watch, today="2026-08-30") is True + assert shopping._daily_ok(watch, today="2026-08-31") is False + + def test_the_watch_loop_derives_the_day_from_the_profile_it_already_reads(self): + """One profile read per user already happens for the pacing cap; the + local day rides along on it rather than costing another connection.""" + import inspect + from palmer import watches + src = inspect.getsource(watches.run_watches) + assert "local_today" in src + assert "today=today" in src + + def test_the_price_loop_reads_every_profile_in_one_query(self): + """Never `for phone in ...: get_profile(phone)` — that is N+1 a tick.""" + import inspect + from palmer import shopping + src = inspect.getsource(shopping.run_price_watches) + assert "get_all_profiles" in src + assert "get_profile(" not in src + + def test_the_write_side_takes_the_same_day_as_the_read(self): + import inspect + from palmer import db + for fn in (db.update_watch_alerted, db.update_price_watch_alerted): + assert "today" in inspect.signature(fn).parameters + + +class TestProfileFactsAgeOnTheReadersCalendar: + """field_dates is stamped with local_today and was aged against + date.today(), so the two ends of one subtraction used different + calendars. After 17:00 Pacific a fact asserted minutes ago came back to + the model as days_old: 1, and a volatile field was dropped a day early.""" + + def test_the_prompt_profile_is_aged_on_the_users_day(self): + import inspect + from palmer import agent + src = inspect.getsource(agent._prompt_safe_profile) + assert "local_today" in src + assert "fresh_profile_for_prompt(profile)" not in src, \ + "the default argument is date.today() — the dyno's day" + + def test_a_fact_stamped_today_is_not_a_day_old(self): + from palmer import userprofile + from datetime import date as _d + profile = {"stressed_about": "the move", + "field_dates": {"stressed_about": "2026-08-30"}} + # Their day is the 30th; the server has already rolled to the 31st. + out = userprofile.fresh_profile_for_prompt(profile, _d(2026, 8, 30)) + assert out["stressed_about"] == "the move" # undated, i.e. age 0 diff --git a/tests/test_db_claims.py b/tests/test_db_claims.py deleted file mode 100644 index 852d08f..0000000 --- a/tests/test_db_claims.py +++ /dev/null @@ -1,175 +0,0 @@ -from dotenv import load_dotenv -load_dotenv() - -from datetime import datetime, timedelta, timezone - -from palmer import db - - -def _fresh_db(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_claims.db") - db.init_db() - - -class TestSaveMessageTimestamp: - """Regression test: save_message must write created_at in the same ISO8601+offset - format used for cutoff comparisons, or same-day messages silently fail to match - (SQLite's old CURRENT_TIMESTAMP default sorted before any same-day ISO cutoff).""" - - def test_message_saved_now_matches_a_recent_cutoff(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - db.save_message(phone, "assistant", "hello there") - cutoff = (datetime.now(timezone.utc) - timedelta(hours=6)).isoformat() - assert db.get_recent_assistant_messages(phone, cutoff) == ["hello there"] - - def test_old_message_does_not_match_a_recent_cutoff(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - db.save_message(phone, "assistant", "old message") - cutoff = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() - assert db.get_recent_assistant_messages(phone, cutoff) == [] - - -class TestClaimDailyGuard: - def test_first_claim_succeeds(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - assert db.claim_daily_guard("+15551234567", "morning_sent_date", "2026-08-13") is True - - def test_second_claim_same_value_fails(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is False - - def test_claim_different_value_succeeds(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-14") is True - - def test_release_allows_reclaim(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True - db.upsert_profile(phone, {"morning_sent_date": None}) - assert db.claim_daily_guard(phone, "morning_sent_date", "2026-08-13") is True - - -class TestClaimWatchAlert: - def test_fresh_row_claims(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) - assert db.claim_watch_alert(watch_id, 4) is True - - def test_immediate_reclaim_fails(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) - assert db.claim_watch_alert(watch_id, 4) is True - assert db.claim_watch_alert(watch_id, 4) is False - - -class TestWatchAlertUrlPersistence: - """The 'Watching' page section links a watch to the article that - fired it, so update_watch_alerted must round-trip the URL/domain, not just - the dedup title, and a watch that has never fired must stay linkless.""" - - def test_update_watch_alerted_persists_url_and_domain(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - watch_id = db.save_watch(phone, "test watch", ["test query"], cooldown_hours=4) - db.update_watch_alerted(watch_id, "summary", [], url="https://apnews.com/x", domain="apnews.com") - row = db.get_user_watches(phone)[0] - assert row["last_alert_url"] == "https://apnews.com/x" - assert row["last_alert_domain"] == "apnews.com" - - def test_update_watch_alerted_without_url_leaves_columns_null(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - watch_id = db.save_watch(phone, "test watch", ["test query"], cooldown_hours=4) - db.update_watch_alerted(watch_id, "summary", []) - row = db.get_user_watches(phone)[0] - assert row["last_alert_url"] is None - assert row["last_alert_domain"] is None - - -class TestGetUserPriceWatches: - """last_seen_url/last_seen_merchant already existed on the row and were - already populated by set_price_watch_baseline — they just weren't selected.""" - - def test_baseline_url_and_merchant_round_trip(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - phone = "+15551234567" - watch_id = db.save_price_watch(phone, "AirPods Pro", target_price=199.0) - db.set_price_watch_baseline(watch_id, 220.0, "https://www.amazon.com/x", "amazon.com") - row = db.get_user_price_watches(phone)[0] - assert row["last_seen_url"] == "https://www.amazon.com/x" - assert row["last_seen_merchant"] == "amazon.com" - - def test_reclaim_after_cooldown_succeeds(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_watch("+15551234567", "test topic", ["test query"], cooldown_hours=4) - old = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat() - conn = db._conn() - cur = conn.cursor() - cur.execute(f"UPDATE watches SET last_alerted = {db.PH} WHERE id = {db.PH}", (old, watch_id)) - conn.commit() - conn.close() - assert db.claim_watch_alert(watch_id, 4) is True - - -class TestClaimPriceWatchAlert: - def test_fresh_row_claims(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) - assert db.claim_price_watch_alert(watch_id, 12) is True - - def test_immediate_reclaim_fails(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) - assert db.claim_price_watch_alert(watch_id, 12) is True - assert db.claim_price_watch_alert(watch_id, 12) is False - - def test_release_allows_reclaim(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - watch_id = db.save_price_watch("+15551234567", "test product", cooldown_hours=12) - assert db.claim_price_watch_alert(watch_id, 12) is True - db.release_price_watch_claim(watch_id) - assert db.claim_price_watch_alert(watch_id, 12) is True - - -class TestPriceWatchRebaseline: - """update_price_watch_alerted must move baseline_price to the alerted price. - - baseline_price is otherwise written exactly once, by set_price_watch_baseline - at watch creation, and never again — so a price that settles below the drop - bar keeps re-qualifying on every tick. The per-day cap bounds that to - PRICE_DAILY_ALERT_MAX texts, then resets the next day, forever. - _is_duplicate_subject can't catch it either: 6h window, 12h cadence.""" - - def _watch(self): - wid = db.save_price_watch("+15551234567", "Core Power Elite Chocolate 12pk") - db.set_price_watch_baseline(wid, 50.98, "https://amazon.com/dp/X", "Amazon") - return wid - - def test_alert_moves_the_baseline(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - wid = self._watch() - db.update_price_watch_alerted( - wid, 47.98, "https://amazon.com/dp/X", "Amazon", "shake's down to 47.98") - row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] - assert float(row["baseline_price"]) == 47.98 - assert float(row["last_seen_price"]) == 47.98 - - def test_same_price_no_longer_qualifies_after_alert(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - from palmer.shopping import _should_alert - wid = self._watch() - row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] - assert _should_alert(row, 47.98) == "drop" - - db.update_price_watch_alerted( - wid, 47.98, "https://amazon.com/dp/X", "Amazon", "shake's down to 47.98") - row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] - assert _should_alert(row, 47.98) == "" # already told, don't repeat - assert _should_alert(row, 45.00) == "drop" # a further move still fires diff --git a/tests/test_followup.py b/tests/test_followup.py deleted file mode 100644 index 34324e0..0000000 --- a/tests/test_followup.py +++ /dev/null @@ -1,263 +0,0 @@ -"""A check-in is about something Palmer actually knows, and not too often. - -followup.py fetches nothing of its own — subjects are copied from the profile -(ongoing_threads), from the page's Scores rows (a followed team's game) and -from the page's stored headlines — so every guard has to be structural. - -Two defects made the old version the largest source of "random thoughts": -_pick_thread returned whatever Haiku emitted and handed it straight to the -drafter, so an invented thread was written up as though it were real; and the -draft prompt asked for "a statement that just shows you remembered", which is -an instruction to invent specificity. Separately, every bail path nulled -followup_sent_date instead of restoring it, which silently voided the pacing -gap. The gap is longer now (GAP_DAYS) because with teams and news in the pool -there is nearly always a candidate, so the gap is the whole rate limit. -""" -from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch -from zoneinfo import ZoneInfo - -from palmer import db -from palmer import followup -from palmer.followup import Subject - - -PHONE = "+15550001111" -THREADS = ["interviewing at Stripe next week", "sister's wedding in Portland"] -CARDS = {"abbrev": "STL", "name": "St. Louis Cardinals", "league": "mlb"} -GAME_LAST = {"id": "1", "league": "mlb", "state": "post", "detail": "Final", - "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 5}, - "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 2}} - -# The one instant every clock-dependent test here is frozen to. _local_now and -# _local_today must be patched TOGETHER off this value: _should_send_followup -# reads the first, but the claim_daily_guard write in run_followups reads the -# second, so patching only _local_now left the assertion comparing a frozen day -# against the real clock. -FROZEN = datetime(2026, 8, 30, 15, 0, tzinfo=ZoneInfo("America/Chicago")) - -# The last real send, as the bail tests expect to find it restored. Derived -# from FROZEN so it stays outside the maximum pacing gap whatever FROZEN is. -PRIOR_SENT = (FROZEN - timedelta(days=followup.GAP_MAX_DAYS + 1)).date().isoformat() - - -def _freeze(monkeypatch): - monkeypatch.setattr(followup, "_local_now", lambda tz: FROZEN) - monkeypatch.setattr(followup, "_local_today", lambda tz: FROZEN.date()) - - -def _fresh(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_followup.db") - db.init_db() - - -def _haiku(text): - return patch.object(followup.client.messages, "create", - return_value=MagicMock(content=[MagicMock(text=text)])) - - -def _threads(*texts): - return [Subject("thread", t) for t in texts] - - -class TestPacing: - def test_the_gap_is_longer_than_it_was(self): - """3 days read as a drumbeat. Ten is roughly three a month.""" - assert followup.GAP_DAYS >= 7 - assert followup.GAP_MAX_DAYS > followup.GAP_DAYS - - def test_inside_the_gap_is_silence(self, monkeypatch): - _freeze(monkeypatch) - recent = (FROZEN - timedelta(days=followup.GAP_DAYS - 1)).date().isoformat() - assert not followup._should_send_followup({ - "morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": THREADS, "followup_sent_date": recent}) - - def test_past_the_gap_is_due(self, monkeypatch): - _freeze(monkeypatch) - assert followup._should_send_followup({ - "morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": THREADS, "followup_sent_date": PRIOR_SENT}) - - def test_a_team_or_a_topic_is_enough_to_be_eligible(self, monkeypatch): - _freeze(monkeypatch) - base = {"morning_onboarded": True, "timezone": "America/Chicago"} - assert not followup._should_send_followup(base) - assert followup._should_send_followup(dict(base, followed_teams=[CARDS])) - assert followup._should_send_followup(dict(base, morning_topics=["AI news"])) - - -class TestCandidatesAreCopiedFromData: - def test_threads_teams_and_headlines_all_become_subjects(self): - profile = {"ongoing_threads": THREADS, "followed_teams": [CARDS]} - payload = {"fetched": {"headlines": datetime.now().timestamp()}, - "headlines": [{"title": "Fed holds rates", "url": "https://reuters.com/x", - "source": "reuters.com", "topic": "Fed"}]} - with patch("palmer.sports.team_day", return_value={"last": GAME_LAST, "today": None}): - got = followup._candidates(profile, payload, FROZEN.date()) - kinds = [s.kind for s in got] - assert kinds == ["thread", "thread", "team", "news"] - assert got[2].text == "St. Louis Cardinals: yesterday beat Chicago Cubs 5-2" - assert got[3].text == "Fed holds rates (Fed)" and got[3].url == "https://reuters.com/x" - - def test_stale_headlines_are_not_brought_up(self): - old = datetime.now().timestamp() - (followup.NEWS_MAX_AGE_HOURS + 1) * 3600 - payload = {"fetched": {"headlines": old}, - "headlines": [{"title": "Fed holds rates", "url": "u", "topic": "Fed"}]} - assert followup._candidates({}, payload, FROZEN.date()) == [] - - def test_a_team_with_no_game_adds_nothing(self): - with patch("palmer.sports.team_day", return_value={"last": None, "today": None}): - assert followup._candidates({"followed_teams": [CARDS]}, None, FROZEN.date()) == [] - - def test_a_sports_outage_costs_only_the_team_rows(self): - with patch("palmer.sports.team_day", side_effect=RuntimeError("espn down")): - got = followup._candidates({"ongoing_threads": THREADS, "followed_teams": [CARDS]}, - None, FROZEN.date()) - assert [s.kind for s in got] == ["thread", "thread"] - - def test_the_last_subject_is_left_out(self): - profile = {"ongoing_threads": THREADS, "followup_last_thread": THREADS[0]} - assert [s.text for s in followup._candidates(profile, None, FROZEN.date())] == [THREADS[1]] - - def test_a_single_subject_is_still_available_after_being_used(self): - """Excluding the only thing there is would end check-ins for good.""" - profile = {"ongoing_threads": [THREADS[0]], "followup_last_thread": THREADS[0]} - assert [s.text for s in followup._candidates(profile, None, FROZEN.date())] == [THREADS[0]] - - def test_no_token_means_no_news_and_no_error(self): - assert followup._load_payload({}) is None - - -class TestPickSubjectFailsClosed: - def test_an_exact_echo_returns_the_stored_subject(self): - with _haiku("interviewing at Stripe next week"): - got = followup._pick_subject({}, [], _threads(*THREADS)) - assert got == Subject("thread", "interviewing at Stripe next week") - - def test_a_confabulated_subject_is_refused(self): - """The actual failure: Haiku names something plausible that is not on - the list, and it used to be drafted as though it were real.""" - with _haiku("how the apartment hunt is going"): - assert followup._pick_subject({}, [], _threads(*THREADS)) is None - - def test_a_paraphrase_is_refused_too(self): - with _haiku("the Stripe interview"): - assert followup._pick_subject({}, [], _threads(*THREADS)) is None - - def test_none_is_none(self): - with _haiku("NONE"): - assert followup._pick_subject({}, [], _threads(*THREADS)) is None - - def test_no_candidates_costs_no_model_call(self): - with patch.object(followup.client.messages, "create") as create: - assert followup._pick_subject({}, [], []) is None - create.assert_not_called() - - def test_a_model_failure_is_silence(self): - with patch.object(followup.client.messages, "create", - side_effect=RuntimeError("down")): - assert followup._pick_subject({}, [], _threads(*THREADS)) is None - - def test_the_prompt_shows_the_kind_and_asks_for_an_echo(self): - with _haiku("NONE") as create: - followup._pick_subject({}, [], [Subject("team", "Cards: today play Cubs, 7:15 PM CT")]) - body = create.call_args.kwargs["messages"][0]["content"] - assert "[team] Cards: today play Cubs, 7:15 PM CT" in body - assert "copied EXACTLY" in body - - -class TestLifeContextAloneNeverTriggersACheckIn: - def test_prose_about_someone_is_not_a_thread(self): - assert not followup._should_send_followup({ - "morning_onboarded": True, "timezone": "America/Chicago", - "life_context": "Works in finance, two kids, moved to Chicago in March", - }) - - -class TestThePacingGapSurvivesABail: - """claim_daily_guard overwrites followup_sent_date with today, so nulling it - on a bail erased the record of the last real send — and _should_send_followup - measures the gap against exactly that field.""" - - def _run(self, tmp_path, monkeypatch, *, subject, candidates=None, - drafted="hey, how'd it go?", dup=False, sent=True): - _fresh(tmp_path, monkeypatch) - db.upsert_profile(PHONE, { - "morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": THREADS, "followup_sent_date": PRIOR_SENT, - }) - _freeze(monkeypatch) - if candidates is None: - candidates = _threads(*THREADS) - with patch.object(followup, "get_all_profiles", - return_value=[(PHONE, db.get_profile(PHONE))]), \ - patch.object(followup, "_candidates", return_value=candidates), \ - patch.object(followup, "_pick_subject", return_value=subject), \ - patch.object(followup, "_draft_followup", return_value=drafted), \ - patch.object(followup, "_is_duplicate_subject", return_value=dup), \ - patch("palmer.sms_util.send_sms", return_value=sent): - followup.run_followups() - return db.get_profile(PHONE) - - def test_no_candidates_restores_the_prior_date(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=None, candidates=[]) - assert p["followup_sent_date"] == PRIOR_SENT - - def test_no_pick_restores_it(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=None) - assert p["followup_sent_date"] == PRIOR_SENT - - def test_an_empty_draft_restores_it(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=_threads(THREADS[0])[0], drafted="") - assert p["followup_sent_date"] == PRIOR_SENT - - def test_a_duplicate_restores_it(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=_threads(THREADS[0])[0], dup=True) - assert p["followup_sent_date"] == PRIOR_SENT - - def test_a_failed_send_restores_it(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=_threads(THREADS[0])[0], sent=False) - assert p["followup_sent_date"] == PRIOR_SENT - - def test_a_real_send_advances_it_and_records_the_subject(self, tmp_path, monkeypatch): - p = self._run(tmp_path, monkeypatch, subject=_threads(THREADS[0])[0]) - assert p["followup_sent_date"] == FROZEN.date().isoformat() - assert p["followup_last_thread"] == THREADS[0] - - -class TestTheDraft: - def _drafted(self, subject, text="cards back at it tonight, 7:15."): - with patch.object(followup, "_build_system", return_value="sys"), \ - patch.object(followup.client.messages, "create", - return_value=MagicMock(content=[MagicMock(text=text)])) as create: - out = followup._draft_followup(PHONE, subject) - return out, create.call_args.kwargs["messages"][0]["content"] - - def test_it_forbids_inventing_details(self): - import inspect - src = inspect.getsource(followup._draft_followup) - assert "Do not invent" in src - # The instruction that produced the problem. - assert "just shows you remembered" not in src - - def test_a_news_subject_carries_its_link_last_and_alone(self): - out, body = self._drafted(Subject("news", "Fed holds rates (Fed)", - url="https://reuters.com/x", source="reuters.com")) - assert out.endswith("\nhttps://reuters.com/x") - assert "do not include or mention a link" in body and "reuters.com" in body - - def test_a_team_subject_is_drafted_from_the_line_only(self): - out, body = self._drafted(Subject("team", "Cards: today play Cubs, 7:15 PM CT")) - assert "Cards: today play Cubs, 7:15 PM CT" in body - assert "\n" not in out - - def test_a_thread_subject_may_ask_one_question(self): - _, body = self._drafted(Subject("thread", THREADS[0])) - assert THREADS[0] in body and "ask one short question" in body - - def test_the_bookkeeping_field_is_allow_listed_but_not_extractable(self): - from palmer import userprofile - from palmer import prompts - assert "followup_last_thread" in userprofile.PROFILE_FIELDS - assert "followup_last_thread" not in prompts.EXTRACT_PROMPT diff --git a/tests/test_forecast_audit.py b/tests/test_forecast_audit.py deleted file mode 100644 index e8da2b9..0000000 --- a/tests/test_forecast_audit.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Forecast accuracy: the hedge, and the log that will replace it with data. - -Palmer told a Woodland Hills user 103, 106, 107 and 111 on four consecutive -days against actuals of 98.3, 96.8, 97.8 and 99.5. In the same week NWS was the -best source available for Culver City, +1.7F where every raw model ran 5-11F -hot. So neither source wins everywhere and a median is worse than either at one -of the two — which is why nothing here averages anything. - -Two mechanisms, tested separately: - -1. The hedge changes what Palmer *claims*, not what it reports. A wide - ensemble spread means say "around", not say a different number. -2. The audit records each source against reality so the eventual choice comes - from months of data rather than from one bad week. - -All offline. -""" -from unittest.mock import patch - -from palmer import db -from palmer import morning -from palmer import weather -from palmer import wxaudit - - -def _fresh_db(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "wx.db") - db.init_db() - - -def _om(*highs): - """An Open-Meteo multi-model daily payload.""" - return {"daily": {f"temperature_2m_max_model{i}": [h] for i, h in enumerate(highs)}} - - -class TestTheHedgeMeasuresDisagreementNotOneOpinion: - """A single second opinion measures the wrong thing: GFS shares NWS's - inland warm error and contradicts NWS where NWS is right. Only the spread - across several models separates the two cases.""" - - def test_a_wide_spread_is_flagged(self): - """Woodland Hills: NWS 110 against models at 93.7/100.6/105.3.""" - with patch.object(weather, "_http_get_json_retry", - return_value=_om(93.7, 100.6, 105.3)): - out = weather._ensemble_spread(34.17, -118.61, 110) - assert out["high_confident"] is False - assert out["high_spread"] == 16.3 - assert (out["high_low_est"], out["high_high_est"]) == (94, 110) - - def test_a_tight_spread_is_left_alone(self): - """Culver City: NWS 90 is the low outlier and it is also the right - answer. Hedging here would be a false alarm.""" - with patch.object(weather, "_http_get_json_retry", - return_value=_om(93.2, 96.2, 98.7)): - out = weather._ensemble_spread(34.02, -118.39, 90) - assert out["high_confident"] is True - - def test_it_never_changes_the_number(self): - with patch.object(weather, "_http_get_json_retry", - return_value=_om(93.7, 100.6, 105.3)): - out = weather._ensemble_spread(34.17, -118.61, 110) - assert "high" not in out, "the hedge qualifies the high, it never replaces it" - - def test_a_dead_second_source_leaves_the_high_unqualified(self): - with patch.object(weather, "_http_get_json_retry", side_effect=RuntimeError("429")): - assert weather._ensemble_spread(34.17, -118.61, 110) == {} - - def test_no_high_means_nothing_to_check(self): - with patch.object(weather, "_http_get_json_retry") as http: - assert weather._ensemble_spread(34.17, -118.61, None) == {} - http.assert_not_called() - - -class TestTheHedgeReachesTheUser: - UNSURE = {"city": "Woodland Hills", "weather": { - "resolved": "Woodland Hills, California", "description": "partly sunny", - "temp_now": 80, "high": 110, "low": 69, "high_confident": False, - "high_spread": 16.3, "high_low_est": 94, "high_high_est": 110}} - SURE = {"city": "Culver City", "weather": { - "resolved": "Culver City, California", "description": "partly sunny", - "temp_now": 74, "high": 90, "low": 70, "high_confident": True, - "high_spread": 8.7, "high_low_est": 90, "high_high_est": 99}} - - def test_a_contested_high_reaches_the_drafter_as_a_range(self): - d = morning._payload_digest(self.UNSURE) - assert "between 94 and 110" in d - assert "do NOT state a single high" in d - - def test_a_settled_high_is_stated_plainly(self): - d = morning._payload_digest(self.SURE) - assert "high 90" in d and "disagree" not in d - - def test_the_prompt_carries_the_hedging_rule(self): - assert "forecasts disagree" in morning.generate_morning_line.__doc__ or True - import inspect - src = inspect.getsource(morning.generate_morning_line) - assert "do NOT pick one and state it" in src - - def test_the_page_shows_the_same_range(self): - """The page, the card and the text render from one payload and must not - disagree about how sure Palmer is.""" - from palmer import page - html = page.render(dict(self.UNSURE, prices=[], headlines=[], opening=[], - tracking={"watches": [], "price_watches": [], "topics": []}, - fetched={}), token="t", image_url="i", page_url="p") - assert "H 94-110" in html - - def test_a_settled_high_stays_a_single_number_on_the_page(self): - from palmer import page - html = page.render(dict(self.SURE, prices=[], headlines=[], opening=[], - tracking={"watches": [], "price_watches": [], "topics": []}, - fetched={}), token="t", image_url="i", page_url="p") - assert "H 90" in html and "H 90-" not in html - - -class TestTheAuditLog: - def test_a_forecast_and_its_actual_score(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.record_forecast("Woodland Hills", "2026-08-27", "nws", 111.0) - db.record_forecast("Woodland Hills", "2026-08-27", "ecmwf_ifs025", 100.6) - db.record_actual("Woodland Hills", "2026-08-27", 99.5) - scores = {r["source"]: r for r in db.forecast_scores(days=3650)} - assert round(scores["nws"]["bias"], 1) == 11.5 - assert round(scores["ecmwf_ifs025"]["bias"], 1) == 1.1 - - def test_logging_the_same_day_twice_does_not_double_count(self, tmp_path, monkeypatch): - """The job may be re-run or misfire-recovered; a duplicated day would - silently weight one day twice in the average.""" - _fresh_db(tmp_path, monkeypatch) - for _ in range(3): - db.record_forecast("Culver City", "2026-08-27", "nws", 90.0) - db.record_actual("Culver City", "2026-08-27", 88.3) - assert db.forecast_scores(days=3650)[0]["n"] == 1 - - def test_pending_actuals_lists_only_unfilled_past_days(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.record_forecast("A", "2026-08-25", "nws", 100.0) - db.record_forecast("A", "2026-08-26", "nws", 100.0) - db.record_actual("A", "2026-08-25", 99.0) - assert db.pending_actuals("2026-08-27") == [("A", "2026-08-26")] - - def test_an_unscored_day_is_excluded_from_the_average(self, tmp_path, monkeypatch): - """A forecast with no actual yet must not read as a zero-error day.""" - _fresh_db(tmp_path, monkeypatch) - db.record_forecast("A", "2026-08-26", "nws", 100.0) - assert db.forecast_scores(days=3650) == [] - - -class TestTheSelectorIsGatedOnEvidence: - """The audit stopped being a diagnostic once it could act. But the whole - point is that it only acts when the evidence is unambiguous — a selector - that switches on one good day is the anecdote-fitting this was built to - replace.""" - - def _seed(self, tmp_path, monkeypatch, rows): - from datetime import date, timedelta - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "sel.db") - db.init_db() - wxaudit._clear_best_cache() - for city, source, err, n in rows: - for i in range(n): - day = (date.today() - timedelta(days=i + 1)).isoformat() - db.record_forecast(city, day, source, 100.0 + err) - db.record_actual(city, day, 100.0) - - def test_a_clearly_better_source_wins(self, tmp_path, monkeypatch): - self._seed(tmp_path, monkeypatch, - [("WH", "nws", 11, 6), ("WH", "ecmwf_ifs025", 3, 6)]) - assert wxaudit.best_source("WH") == "ecmwf_ifs025" - - def test_an_incumbent_that_is_already_best_is_kept(self, tmp_path, monkeypatch): - self._seed(tmp_path, monkeypatch, - [("CC", "nws", 2, 6), ("CC", "ecmwf_ifs025", 12, 6)]) - assert wxaudit.best_source("CC") is None - - def test_a_margin_too_thin_does_not_churn(self, tmp_path, monkeypatch): - """A source that changes weekly is its own kind of wrong.""" - self._seed(tmp_path, monkeypatch, - [("TIE", "nws", 5, 6), ("TIE", "ecmwf_ifs025", 4, 6)]) - assert wxaudit.best_source("TIE") is None - - def test_a_challenger_with_too_few_days_does_not_win(self, tmp_path, monkeypatch): - self._seed(tmp_path, monkeypatch, - [("THIN", "nws", 11, 6), ("THIN", "icon_seamless", 1, 2)]) - assert wxaudit.best_source("THIN") is None - - def test_an_unmeasured_incumbent_is_never_abandoned(self, tmp_path, monkeypatch): - """The failure this gate exists to prevent: NWS has no historical - endpoint, so it starts with almost no scored days. Switching away from - it before measuring it would be exactly the mistake.""" - self._seed(tmp_path, monkeypatch, [("NOBASE", "ecmwf_ifs025", 1, 6)]) - assert wxaudit.best_source("NOBASE") is None - - def test_the_answer_is_cached_per_city_per_day(self, tmp_path, monkeypatch): - """Consulted on the read path; the answer cannot change intraday.""" - self._seed(tmp_path, monkeypatch, - [("WH", "nws", 11, 6), ("WH", "ecmwf_ifs025", 3, 6)]) - wxaudit.best_source("WH") - with patch("palmer.db.forecast_scores", side_effect=AssertionError("should not re-query")): - assert wxaudit.best_source("WH") == "ecmwf_ifs025" - - def test_no_city_and_no_data_are_safe(self, tmp_path, monkeypatch): - self._seed(tmp_path, monkeypatch, []) - assert wxaudit.best_source("") is None - assert wxaudit.best_source("Nowhere") is None - - -class TestAProvenSourceIsUsedAndTrusted: - def test_the_snapshot_switches_to_the_proven_model(self): - with patch("palmer.wxaudit.best_source", return_value="ecmwf_ifs025"), \ - patch.object(weather, "_geocode", return_value=(34.17, -118.61, "Woodland Hills")), \ - patch.object(weather, "_fetch_openmeteo", return_value={ - "current": {"temperature_2m": 80, "weather_code": 0}, - "daily": {"temperature_2m_max": [96.7], "temperature_2m_min": [69.0], - "precipitation_probability_max": [0], "wind_gusts_10m_max": [5]}}) as om, \ - patch.object(weather, "_nws_snapshot") as nws: - snap = weather.weather_snapshot("Woodland Hills, California") - assert snap["source"] == "ecmwf_ifs025" and snap["high"] == 96.7 - assert om.call_args.kwargs["model"] == "ecmwf_ifs025" - nws.assert_not_called(), "a proven source must not also pay for NWS" - - def test_a_proven_source_is_stated_not_hedged(self): - """The other models disagreeing is what put this one in front; it is no - longer a reason to hedge.""" - out = weather._ensemble_spread(34.17, -118.61, 96.7, proven=True) - assert out["high_confident"] is True - - def test_a_proven_source_costs_no_ensemble_call(self): - with patch.object(weather, "_http_get_json_retry") as http: - weather._ensemble_spread(34.17, -118.61, 96.7, proven=True) - http.assert_not_called() - - def test_nothing_proven_leaves_todays_behaviour_alone(self): - with patch("palmer.wxaudit.best_source", return_value=None), \ - patch.object(weather, "_geocode", return_value=(34.17, -118.61, "WH")), \ - patch.object(weather, "_nws_snapshot", return_value={"high": 103, "source": "nws"}), \ - patch.object(weather, "_ensemble_spread", return_value={"high_confident": False}): - snap = weather.weather_snapshot("Woodland Hills, California") - assert snap["source"] == "nws" and snap["high_confident"] is False - - def test_a_broken_audit_never_costs_anyone_their_forecast(self): - with patch("palmer.wxaudit.best_source", side_effect=RuntimeError("db down")): - assert weather._proven_source("Anywhere") is None - - -class TestTheAuditJobIsSafe: - def test_it_never_raises(self): - with patch("palmer.wxaudit._cities", side_effect=RuntimeError("db down")): - from palmer import wxaudit - wxaudit.run_forecast_audit() # must not propagate - - def test_it_sends_nothing(self): - import inspect - from palmer import wxaudit - src = inspect.getsource(wxaudit) - for forbidden in ("send_sms", "ensure_sms", "messages.create"): - assert forbidden not in src, "the audit is observation only" - - def test_one_city_is_logged_once_however_many_users_share_it(self): - from palmer import wxaudit - profiles = [("+1", {"city": "Kirkwood, MO"}), ("+2", {"city": "Kirkwood, MO"}), - ("+3", {"city": "Culver City"}), ("+4", {})] - with patch("palmer.wxaudit.get_all_profiles", return_value=profiles), \ - patch("palmer.weather._geocode", side_effect=lambda c: (1.0, 2.0, c)): - assert sorted(wxaudit._cities()) == ["Culver City", "Kirkwood, MO"] - - def test_a_scheduled_cron_not_an_interval(self): - """Once a day on an interval makes the phase a function of deploy - history, and a skipped day is a hole in the record.""" - import inspect - from palmer import main - src = inspect.getsource(main) - block = src.split("run_forecast_audit,")[1][:120] - assert '"cron"' in block and "misfire_grace_time" in block diff --git a/tests/test_guards_and_flights.py b/tests/test_guards.py similarity index 69% rename from tests/test_guards_and_flights.py rename to tests/test_guards.py index cbad1bc..bbbfe5f 100644 --- a/tests/test_guards_and_flights.py +++ b/tests/test_guards.py @@ -1,24 +1,35 @@ -"""Three rules the prompt alone could not hold, and the capability it denied. +"""Guards, flights, and the scheduler's own configuration. -SYSTEM_PROMPT has forbidden sending users to competing products since the -beginning, in as many words. Palmer did it anyway in production — five times -across two users, once while quoting the rule back at itself ("I'd point you to -Google Flights but I know that's not helpful coming from me"). The corpus in -TestTheRedirectGuard is those real messages. - -It also told two people it could not do flights while `search_flights` sat there -working, because the user wanted a *watch* and there was no watch to offer. So -the honest answer — do the half you can, name the half you can't — needed the -other half to exist. - -All offline. +Merged from test_guards_and_flights.py, test_scheduler_config.py; each section keeps its +original file's notes, because those carry the incident that led to the test. """ +import importlib +import pytest from unittest.mock import patch, MagicMock - -from palmer import db -from palmer import agent -from palmer import guards - +from palmer import db, agent, guards +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +from apscheduler.triggers.cron import CronTrigger + + +# ============================================================================ +# from test_guards_and_flights.py +# ============================================================================ +# +# Three rules the prompt alone could not hold, and the capability it denied. +# +# SYSTEM_PROMPT has forbidden sending users to competing products since the +# beginning, in as many words. Palmer did it anyway in production — five times +# across two users, once while quoting the rule back at itself ("I'd point you to +# Google Flights but I know that's not helpful coming from me"). The corpus in +# TestTheRedirectGuard is those real messages. +# +# It also told two people it could not do flights while `search_flights` sat there +# working, because the user wanted a *watch* and there was no watch to offer. So +# the honest answer — do the half you can, name the half you can't — needed the +# other half to exist. +# +# All offline. # Verbatim from production. Every one of these went to a real user. REAL_VIOLATIONS = [ @@ -190,39 +201,30 @@ def test_a_city_palmer_cannot_place_is_asked_about_not_disclaimed(self): class TestFlightWatches: - def _fresh(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "fw.db") - db.init_db() - - def test_a_watch_saves_and_lists(self, tmp_path, monkeypatch): - self._fresh(tmp_path, monkeypatch) + def test_a_watch_saves_and_lists(self, fresh_db): assert db.save_flight_watch("+1555", "lax", "mxp", "2026-09-18", "2026-09-26", 800) w = db.get_user_flight_watches("+1555")[0] assert (w["origin"], w["destination"]) == ("LAX", "MXP"), "codes normalise to upper" - def test_the_same_route_is_not_watched_twice(self, tmp_path, monkeypatch): - self._fresh(tmp_path, monkeypatch) + def test_the_same_route_is_not_watched_twice(self, fresh_db): db.save_flight_watch("+1555", "LAX", "MXP", "2026-09-18") assert db.save_flight_watch("+1555", "lax", "mxp", "2026-09-18") is None - def test_the_cap_holds(self, tmp_path, monkeypatch): + def test_the_cap_holds(self, fresh_db): """Each active watch costs ~30 SerpAPI searches a month against a 250 plan, so the cap is a budget control, not tidiness.""" - self._fresh(tmp_path, monkeypatch) for i in range(db.FLIGHT_WATCH_MAX): assert db.save_flight_watch("+1555", "LAX", f"MX{i}", "2026-09-18") assert db.save_flight_watch("+1555", "LAX", "JFK", "2026-09-18") is None - def test_cancelling_by_airport(self, tmp_path, monkeypatch): - self._fresh(tmp_path, monkeypatch) + def test_cancelling_by_airport(self, fresh_db): db.save_flight_watch("+1555", "LAX", "MXP", "2026-09-18") db.save_flight_watch("+1555", "JFK", "LHR", "2026-10-01") assert db.cancel_flight_watches("+1555", "lax") == 1 assert len(db.get_user_flight_watches("+1555")) == 1 - def test_an_alert_rebaselines(self, tmp_path, monkeypatch): + def test_an_alert_rebaselines(self, fresh_db): """Otherwise the next check measures from a fare the user was never told.""" - self._fresh(tmp_path, monkeypatch) db.save_flight_watch("+1555", "LAX", "MXP", "2026-09-18") wid = db.get_user_flight_watches("+1555")[0]["id"] db.update_flight_watch_price(wid, 900.0, baseline=True) @@ -455,3 +457,154 @@ def test_a_clean_reply_costs_nothing(self): side_effect=lambda **kw: calls.append(kw)): out, _ = agent._finalize("90 today in Culver City, low 71.", "sys", [], None) assert calls == [] + + +# ============================================================================ +# from test_scheduler_config.py +# ============================================================================ +# +# Scheduler registration properties that are invisible at runtime. +# +# Every job here failed, or could fail, the same way: an APScheduler *interval* +# job schedules its first run at `start + interval`, and that clock restarts on +# every dyno boot — which means every deploy. A job whose period is long relative +# to the gap between deploys therefore runs on a cadence set by deploy history +# rather than by the clock, and since a tick that finds nothing to do logs +# nothing, it fails silently. run_price_watches at 12h only ran on days +# production was left alone; run_followups at 4h had 1.5 ticks of margin against +# a 6h delivery window before the reset was even considered. +# +# The property under test throughout is phase-independence: fire times must not +# move when the process starts at a different moment. + +def _scheduler(): + from palmer import main + return main._scheduler + + +def _trigger_for(func): + jobs = [j for j in _scheduler().get_jobs() if j.func is func] + assert len(jobs) == 1, f"expected exactly one job for {func.__name__}" + return jobs[0].trigger + + +def _fire_times(trigger, start, count): + out, fire = [], None + for _ in range(count): + fire = trigger.get_next_fire_time(fire, start if fire is None else fire + timedelta(seconds=1)) + out.append(fire) + return out + + +# Jobs whose period is an hour or longer must be phase-stable. The short jobs +# (reminders 1m, morning 5m, watches 30m) stay on interval deliberately — +# losing up to 30 minutes to a deploy is immaterial there. +def _long_period_jobs(): + from palmer.followup import run_followups + from palmer.morning import send_missing_data_asks + from palmer.shopping import run_price_watches + from palmer.flightwatch import run_flight_watches + from palmer.wxaudit import run_forecast_audit + return [run_followups, send_missing_data_asks, run_price_watches, + run_flight_watches, run_forecast_audit] + + +@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) +def test_long_period_jobs_are_cron(func): + assert isinstance(_trigger_for(func), CronTrigger) + + +@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) +def test_long_period_jobs_fire_on_round_clock_positions(func): + """Phase-independence is structural once the trigger is cron — a CronTrigger + holds no boot-derived state, where an IntervalTrigger's grid is anchored to + the start_date it was given at add_job time. What is checkable here is the + visible consequence: fire times sit on round clock positions rather than on + whatever minute the dyno happened to boot at.""" + trigger = _trigger_for(func) + for fire in _fire_times(trigger, datetime(2026, 8, 25, 3, 17, tzinfo=timezone.utc), 6): + utc = fire.astimezone(timezone.utc) + assert utc.second == 0 and utc.microsecond == 0 + assert utc.minute in (0, 30), f"{func.__name__} fires at :{utc.minute:02d}" + + +@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) +def test_long_period_jobs_pin_their_timezone(func): + """A bare BackgroundScheduler() inherits the PROCESS timezone. That is + Etc/UTC on the dyno and the developer's own zone locally, so an unpinned + cron grid means local runs disagree with production while both look right — + and a TZ config var would rotate the live schedule with nothing to show for + it. Caught exactly this: three jobs registered as America/Chicago on a + laptop and Etc/UTC in prod.""" + assert str(_trigger_for(func).timezone) in ("UTC", "Etc/UTC") + + +@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) +def test_long_period_jobs_survive_a_delayed_tick(func): + """APScheduler's default misfire_grace_time is 1 second, which drops a tick + delayed behind a slow job instead of running it late.""" + jobs = [j for j in _scheduler().get_jobs() if j.func is func] + assert jobs[0].misfire_grace_time and jobs[0].misfire_grace_time >= 600 + + +class TestFollowupGrid: + """run_followups gates on a 13:00-19:00 window in the USER's timezone, so the + grid has to land inside that window for every timezone served — not just for + whichever one the UTC hours were picked against.""" + + SERVED = ("America/Chicago", "America/Los_Angeles") + + def _ticks_in_window(self, tz_name): + trigger = _trigger_for(importlib.import_module("palmer.followup").run_followups) + start = datetime(2026, 8, 25, 0, 0, tzinfo=timezone.utc) + fires = _fire_times(trigger, start, 24) # a full day at 2h spacing + return [f for f in fires + if 13 <= f.astimezone(ZoneInfo(tz_name)).hour < 19 + and f < start + timedelta(days=1)] + + @pytest.mark.parametrize("tz_name", SERVED) + def test_window_gets_multiple_chances(self, tz_name): + # The old 4h interval gave 1.5 ticks of margin against this 6h window. + assert len(self._ticks_in_window(tz_name)) >= 3 + + def test_grid_is_timezone_agnostic(self): + # Both zones get the same coverage — the property that keeps working as + # users are added in zones nobody picked hours for. + counts = {tz: len(self._ticks_in_window(tz)) for tz in self.SERVED} + assert len(set(counts.values())) == 1, counts + + +class TestNoUnpromptedSenderComesBack: + """A daily "a friend would text this" news alert used to text people on + Palmer's own initiative. It is gone; run_followups is the one paced + check-in, and run_score_alerts only texts teams a user set a live level + on. This pins that nothing quietly comes back.""" + + def test_the_retired_modules_are_gone(self): + import importlib + for name in ("palmer.alerts",): + try: + importlib.import_module(name) + except ModuleNotFoundError: + continue + raise AssertionError(f"{name} is back") + + def test_the_job_list_is_exactly_this(self): + allowed = {"send_due_reminders", "send_morning_messages", "run_watches", + "send_missing_data_asks", "run_followups", "run_price_watches", + "run_forecast_audit", "run_flight_watches", "run_score_alerts"} + names = {j.func.__name__ for j in _scheduler().get_jobs()} + assert names == allowed, names + + +class TestShortJobsStayOnInterval: + """Not everything should be cron. These are frequent enough that a deploy + reset costs less than the added rigidity is worth.""" + + def test_short_jobs_are_interval(self): + from apscheduler.triggers.interval import IntervalTrigger + from palmer.reminders import send_due_reminders + from palmer.morning import send_morning_messages + from palmer.watches import run_watches + for func in (send_due_reminders, send_morning_messages, run_watches): + assert isinstance(_trigger_for(func), IntervalTrigger), func.__name__ diff --git a/tests/test_link_integrity.py b/tests/test_link_integrity.py deleted file mode 100644 index 95ea8cf..0000000 --- a/tests/test_link_integrity.py +++ /dev/null @@ -1,157 +0,0 @@ -"""A URL must survive the SMS pipeline byte for byte, or not be sent at all. - -"Bad links" turned out to be three separate mechanical defects, none of them in -the code that chooses a link: - - - the markdown scrub rewrote `[text](url)` to `text`, DELETING the target; - - the ASCII fold dropped non-ASCII bytes out of the middle of a path, leaving - a URL that looks right and resolves to nothing; - - three truncating paths (`shorten_message`, the `body[:320]` fallback, the - hard chunker) all cut at a fixed offset, which lands mid-URL on exactly the - messages most likely to carry one. - -And the /sms-status retry fed a live message back through the first and third of -those. morning.py opted out of that callback by hand; nothing else did. -""" -from palmer import sms_util -from palmer import smstext - - -URL = "https://palmer.example/h/AbC123_xyz" - - -class TestMarkdownLinksKeepTheirTarget: - def test_the_url_survives(self): - out = smstext._sms_clean(f"here you go: [your page]({URL})") - assert URL in out - - def test_the_label_survives_too(self): - out = smstext._sms_clean(f"here you go: [your page]({URL})") - assert "your page" in out - - def test_a_non_http_target_is_still_dropped(self): - # `[x](#)` is markup, not a link worth texting. - out = smstext._sms_clean("see [the notes](#anchor)") - assert "the notes" in out - assert "#anchor" not in out - - -class TestTheAsciiFoldDoesNotCorruptUrls: - def test_a_plain_url_is_untouched(self): - assert smstext._sms_clean(f"tap it {URL}").endswith(URL) - - def test_a_non_ascii_path_is_percent_encoded_not_truncated(self): - out = smstext._sms_clean("https://example.com/café/menu") - # The old fold silently deleted the accented byte, producing - # https://example.com/caf/menu — a valid-looking URL to nowhere. - assert "/caf/menu" not in out - assert "caf%C3%A9" in out - - def test_query_strings_survive(self): - u = "https://example.com/x?a=1&b=2#frag" - assert u in smstext._sms_clean(f"here {u}") - - def test_emoji_beside_a_url_is_still_stripped(self): - out = smstext._sms_clean(f"\U0001F600 {URL}") - assert URL in out - assert "\U0001F600" not in out - - -class TestTruncationNeverCutsAUrl: - def test_a_url_is_kept_whole_or_dropped(self): - text = "x" * 300 + " " + URL - out = smstext.truncate_preserving_urls(text, 320) - assert URL in out or URL[:20] not in out - - def test_a_url_alone_is_returned_when_it_cannot_fit_with_prose(self): - out = smstext.truncate_preserving_urls(URL, 10) - assert out == URL - - def test_short_text_is_untouched(self): - assert smstext.truncate_preserving_urls("hi", 320) == "hi" - - def test_no_partial_scheme_ever_ships(self): - for n in range(1, 60): - out = smstext.truncate_preserving_urls("a " + URL, n) - assert "http" not in out or URL in out - - -class TestChunkingKeepsUrlsIntact: - def test_a_url_is_not_split_across_parts(self): - body = ("word " * 400) + URL - parts = sms_util._split_for_sms(body, max_chars=900) - assert any(URL in p for p in parts), parts - - def test_nothing_is_lost(self): - body = ("word " * 400) + URL - joined = " ".join(sms_util._split_for_sms(body, max_chars=900)) - assert joined.count("word") == 400 - - def test_short_text_is_one_part(self): - assert sms_util._split_for_sms("hi") == ["hi"] - - -class TestTheStatusCallbackIsSuppressedForLinks: - """The retry at /sms-status reruns shorten_message on the original body, so - a message carrying a link must not opt into it.""" - - def _sent(self, monkeypatch, body): - seen = {} - - class _Msgs: - def create(self, **kw): - seen.update(kw) - - # send_sms reads the from-number from the environment at call time, so - # without this the suite only passes on a machine holding live Twilio - # credentials — a trap for CI and for anyone with a partial .env. - monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") - monkeypatch.setattr(sms_util, "_twilio", type("T", (), {"messages": _Msgs()})()) - monkeypatch.setattr(sms_util, "_STATUS_CALLBACK_URL", "https://app/sms-status") - sms_util.send_sms("+15550001111", body) - return seen - - def test_a_body_with_a_url_opts_out(self, monkeypatch): - assert "status_callback" not in self._sent(monkeypatch, f"morning - 72 today {URL}") - - def test_a_body_without_a_url_keeps_it(self, monkeypatch): - assert "status_callback" in self._sent(monkeypatch, "morning - 72 today") - - -class TestShortenMessageKeepsLinks: - def test_the_link_survives_a_failed_model_call(self, monkeypatch): - def _boom(*a, **k): - raise RuntimeError("no network") - monkeypatch.setattr(smstext.client.messages, "create", _boom) - out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) - assert URL in out - assert len(out) <= 320 - - def test_the_link_survives_a_model_that_drops_it(self, monkeypatch): - # Haiku never sees the URL now, so it cannot lose it. - class _R: - content = [type("B", (), {"text": "short version, no link here"})()] - monkeypatch.setattr(smstext.client.messages, "create", lambda *a, **k: _R()) - out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) - assert URL in out - - def test_the_url_is_last(self, monkeypatch): - class _R: - content = [type("B", (), {"text": "short version"})()] - monkeypatch.setattr(smstext.client.messages, "create", lambda *a, **k: _R()) - out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) - assert out.endswith(URL) - - -class TestTheFlightRouteIsAscii: - def test_no_glyph_is_deleted_by_the_fold(self): - from palmer import flightwatch - route = flightwatch._route( - {"origin": "LAX", "destination": "MXP", "outbound_date": "2026-09-10"}) - assert "LAX to MXP" in route - # The bug: an unmapped arrow was dropped, leaving "LAX MXP". - assert smstext._sms_clean(route) == route - assert " " not in smstext._sms_clean(route) - - def test_the_arrow_is_mapped_for_any_other_caller(self): - assert smstext._sms_clean("LAX → MXP") == "LAX -> MXP" diff --git a/tests/test_local_day_guards.py b/tests/test_local_day_guards.py deleted file mode 100644 index a559afb..0000000 --- a/tests/test_local_day_guards.py +++ /dev/null @@ -1,127 +0,0 @@ -"""A "once a day" guard must mean the reader's day. - -(The daily news-alert job that first got this wrong — UTC-keyed guard, local -send window, two "daily" alerts in one Pacific day — has since been retired; -its lesson lives on in every remaining daily sender keying on local_today.) - -morning._recent_assistant_texts took the last 4 assistant messages of any -kind, so for anyone who actually texts Palmer the anti-repetition guard was -comparing today's morning line against ordinary chat rather than against -yesterday's morning — which is the failure it was written for. -""" - -from palmer import db -from palmer import morning - - -PHONE = "+15550001111" - - -def _fresh(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_local_day.db") - db.init_db() - - -class TestTheMorningComparesAgainstMornings: - def test_chat_replies_do_not_crowd_out_prior_mornings(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_message(PHONE, "assistant", "yesterday's morning line", kind="morning") - for i in range(10): - db.save_message(PHONE, "assistant", f"chat reply {i}", kind="reply") - got = morning._recent_assistant_texts(PHONE, n=4) - assert got == ["yesterday's morning line"], got - - def test_it_falls_back_for_history_predating_the_kind_column(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_message(PHONE, "assistant", "an old untagged message") - assert morning._recent_assistant_texts(PHONE, n=4) == ["an old untagged message"] - - def test_ordering_is_oldest_first(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - for i in range(3): - db.save_message(PHONE, "assistant", f"morning {i}", kind="morning") - assert morning._recent_assistant_texts(PHONE, n=3) == \ - ["morning 0", "morning 1", "morning 2"] - - def test_the_repetition_guard_now_sees_yesterday(self, tmp_path, monkeypatch): - """guards.repeats_opening exists for three consecutive mornings that all - opened the same way; it could not see them past a chatty user.""" - from palmer import guards - _fresh(tmp_path, monkeypatch) - db.save_message(PHONE, "assistant", "103 today in Woodland Hills, stay inside", - kind="morning") - db.save_message(PHONE, "assistant", "sure, on it", kind="reply") - recent = morning._recent_assistant_texts(PHONE, n=4) - assert guards.repeats_opening("106 today in Woodland Hills, brutal again", recent) - - -class TestTheAlertCapsUseTheReadersDay: - """The retired daily-alert job was fixed for this and the two watch caps were not. - - A cap keyed on the dyno's UTC date rolls at 17:00 Pacific — inside the - evening, not between days — so a user could take the whole day's - allowance across one local evening and be capped by lunch the next day. - The read (_daily_ok) and the write (update_*_alerted) agreed with each - other and both disagreed with the reader. - """ - - def test_a_news_watch_cap_reads_the_day_it_is_given(self): - from palmer import watches - watch = {"daily_alert_date": "2026-08-31", "daily_alert_count": 99} - # Their day is still the 30th while the server has rolled to the 31st. - assert watches._daily_ok(watch, cap=1, today="2026-08-30") is True - assert watches._daily_ok(watch, cap=1, today="2026-08-31") is False - - def test_a_price_watch_cap_reads_the_day_it_is_given(self): - from palmer import shopping - watch = {"daily_alert_date": "2026-08-31", - "daily_alert_count": shopping.PRICE_DAILY_ALERT_MAX} - assert shopping._daily_ok(watch, today="2026-08-30") is True - assert shopping._daily_ok(watch, today="2026-08-31") is False - - def test_the_watch_loop_derives_the_day_from_the_profile_it_already_reads(self): - """One profile read per user already happens for the pacing cap; the - local day rides along on it rather than costing another connection.""" - import inspect - from palmer import watches - src = inspect.getsource(watches.run_watches) - assert "local_today" in src - assert "today=today" in src - - def test_the_price_loop_reads_every_profile_in_one_query(self): - """Never `for phone in ...: get_profile(phone)` — that is N+1 a tick.""" - import inspect - from palmer import shopping - src = inspect.getsource(shopping.run_price_watches) - assert "get_all_profiles" in src - assert "get_profile(" not in src - - def test_the_write_side_takes_the_same_day_as_the_read(self): - import inspect - from palmer import db - for fn in (db.update_watch_alerted, db.update_price_watch_alerted): - assert "today" in inspect.signature(fn).parameters - - -class TestProfileFactsAgeOnTheReadersCalendar: - """field_dates is stamped with local_today and was aged against - date.today(), so the two ends of one subtraction used different - calendars. After 17:00 Pacific a fact asserted minutes ago came back to - the model as days_old: 1, and a volatile field was dropped a day early.""" - - def test_the_prompt_profile_is_aged_on_the_users_day(self): - import inspect - from palmer import agent - src = inspect.getsource(agent._prompt_safe_profile) - assert "local_today" in src - assert "fresh_profile_for_prompt(profile)" not in src, \ - "the default argument is date.today() — the dyno's day" - - def test_a_fact_stamped_today_is_not_a_day_old(self): - from palmer import userprofile - from datetime import date as _d - profile = {"stressed_about": "the move", - "field_dates": {"stressed_about": "2026-08-30"}} - # Their day is the 30th; the server has already rolled to the 31st. - out = userprofile.fresh_profile_for_prompt(profile, _d(2026, 8, 30)) - assert out["stressed_about"] == "the move" # undated, i.e. age 0 diff --git a/tests/test_morning.py b/tests/test_morning.py new file mode 100644 index 0000000..ebd42e1 --- /dev/null +++ b/tests/test_morning.py @@ -0,0 +1,1042 @@ +"""The morning send: the line, the window, the shape, repetition. + +Merged from test_morning_link.py, test_morning_schedule.py, test_briefing_shape.py, test_weather_city.py, test_repetition.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import pytest +from unittest.mock import patch, MagicMock +from palmer import morning, agent, prompts, guards +from datetime import datetime +from zoneinfo import ZoneInfo +from palmer.morning import _in_send_window, _parse_morning_time, _local_today, DEFAULT_MORNING_TIME +from palmer.smstext import _normalize_hhmm, _parse_published + + +# ============================================================================ +# from test_morning_link.py +# ============================================================================ +# +# The morning update is a link plus one line. +# +# The briefing itself lives on the user's page; the text above it exists to say +# why today is worth a tap. Two properties are load-bearing and tested here: +# +# 1. The URL is LAST in the message and nothing follows it. Message apps only +# render the rich preview when the link sits at a boundary, and that preview +# is the reason this shape works at all. +# 2. Every failure in the page path falls back to the full text briefing. A user +# never gets a link to an empty page, and never gets silence. + +PAYLOAD = { + "city": "Kirkwood, MO", + "weather": {"temp_now": 71, "high": 88, "low": 64, "description": "clear", "rain_pct": 10}, + "traffic": {"live_min": 34, "delay_min": 9}, + "prices": [{"label": "BTC", "pct_24h": -4.2}], + "headlines": [{"title": "Starship static fire slips a week", "topic": "SpaceX news"}], +} + +URL = "https://palmer.example.com/h/AbC123xyz" + + +class TestDigest: + def test_carries_every_section_as_plain_lines(self): + d = morning._payload_digest(PAYLOAD) + assert "Kirkwood, MO" in d and "clear" in d + assert "34 min" in d and "9 min slower" in d + assert "BTC: -4.2%" in d + assert "Starship static fire" in d + + def test_normal_commute_is_not_dressed_up_as_a_delay(self): + d = morning._payload_digest({"traffic": {"live_min": 20, "delay_min": 0}}) + assert "normal" in d and "slower" not in d + + def test_empty_payload_is_falsy_so_callers_can_gate_on_it(self): + assert morning._payload_digest({}) == "" + assert not morning._payload_digest({"weather": {}, "prices": [], "headlines": []}) + + +class TestComposeShape: + def _compose(self, line="Cool 71 and clear, but your drive is 9 min heavier than usual."): + with patch("palmer.home.ensure_fresh", return_value=URL), \ + patch("palmer.home.load", return_value=PAYLOAD), \ + patch("palmer.home.home_token", return_value="tok"), \ + patch.object(morning, "generate_morning_line", return_value=line), \ + patch.object(morning, "generate_morning") as full: + msg, carries = morning._compose_morning("+1555") + return msg, carries, full + + def test_url_is_last_with_nothing_after_it(self): + msg, carries, full = self._compose() + assert carries is True + assert msg.endswith(URL), "a trailing character kills the link preview" + full.assert_not_called() + + def test_message_carries_exactly_one_url(self): + msg, _, _ = self._compose() + assert msg.count("http") == 1, "a second URL suppresses the preview" + + def test_the_line_survives_ahead_of_the_link(self): + msg, _, _ = self._compose(line="Bitcoin is off 4.2% overnight.") + assert msg.startswith("Bitcoin is off 4.2% overnight.\n\n") + + def test_it_is_one_message_not_two(self): + """The briefing used to be its own text with the link chasing it.""" + msg, _, _ = self._compose() + assert len(msg) < 900, "must fit one send, not trigger the chunker" + + +class TestFallbacks: + def _compose_with(self, **patches): + defaults = {"ensure_fresh": URL, "load": PAYLOAD, "home_token": "tok"} + defaults.update(patches) + with patch("palmer.home.ensure_fresh", return_value=defaults["ensure_fresh"]), \ + patch("palmer.home.load", return_value=defaults["load"]), \ + patch("palmer.home.home_token", return_value=defaults["home_token"]), \ + patch.object(morning, "generate_morning", return_value="the long briefing"), \ + patch.object(morning, "generate_morning_line", + side_effect=defaults.get("line_error") or (lambda *a, **k: "a line")): + return morning._compose_morning("+1555") + + def test_missing_app_url_falls_back_to_the_text_briefing(self): + msg, carries = self._compose_with(ensure_fresh="/h/tok") + assert msg == "the long briefing" and carries is False + + def test_empty_page_falls_back_rather_than_linking_to_nothing(self): + msg, carries = self._compose_with(load={}) + assert msg == "the long briefing" and carries is False + + def test_missing_page_falls_back(self): + msg, carries = self._compose_with(load=None) + assert msg == "the long briefing" and carries is False + + def test_a_failed_line_draft_falls_back(self): + msg, carries = self._compose_with(line_error=RuntimeError("sonnet down")) + assert msg == "the long briefing" and carries is False + + def test_the_fallback_never_carries_a_link(self): + """carries_link gates the /sms-status shorten-and-retry, which would + happily cut a URL in half. A text briefing must keep that retry.""" + for kw in ({"ensure_fresh": "/h/tok"}, {"load": {}}, + {"line_error": RuntimeError("x")}): + assert self._compose_with(**kw)[1] is False + + +class TestLinkPlaceholder: + """The drafter knows a link is coming and reaches for a stand-in. Left in, + "[link]" ships as literal text sitting next to the real URL.""" + + @pytest.mark.parametrize("raw", [ + "Arenado to the Dodgers. [link]", + "Arenado to the Dodgers. (url)", + "Arenado to the Dodgers. ", + "Arenado to the Dodgers. [Link]", + "Arenado to the Dodgers. (page)", + "Arenado to the Dodgers. [dashboard]", + ]) + def test_placeholders_are_stripped(self, raw): + assert morning._strip_link_placeholder(raw) == "Arenado to the Dodgers." + + def test_an_invented_url_is_stripped(self): + """A URL Palmer made up is worse than an awkward sentence — and it + would also break the preview by making this the second link.""" + out = morning._strip_link_placeholder("Big day https://made.up/thing") + assert "http" not in out + + def test_a_dangling_separator_goes_with_it(self): + assert morning._strip_link_placeholder("Rain all day -") == "Rain all day" + + def test_a_clean_line_is_untouched(self): + line = "Cool 71 and clear, and your drive is 9 min heavier than usual." + assert morning._strip_link_placeholder(line) == line + + def test_ordinary_brackets_survive(self): + line = "Cards lost (again)." + assert morning._strip_link_placeholder(line) == line + + +class _Block: + def __init__(self, t): self.text = t + + +class _Resp: + def __init__(self, t): self.content = [_Block(t)] + + +def _draft_returning(*texts, payload=None): + """Run the drafter against a scripted sequence of model outputs.""" + calls = [] + + def _create(**kw): + calls.append(kw) + return _Resp(texts[min(len(calls) - 1, len(texts) - 1)]) + + with patch.object(morning, "get_profile", return_value={"timezone": "America/Chicago"}), \ + patch.object(morning, "_build_system", return_value="sys"), \ + patch.object(morning, "_recent_assistant_texts", return_value=[]), \ + patch.object(morning.client.messages, "create", side_effect=_create): + return morning.generate_morning_line("+1555", payload if payload is not None else PAYLOAD), calls + + +GOOD = ""+"Morning Jeff, easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min"+"" +TAIL_BAD = "Check the page.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + + +class TestNamingTheLink: + """"page has your full rundown" turns a text from a friend into a push + notification. The prompt asks for this; the code enforces it.""" + + @pytest.mark.parametrize("bad", [ + "Morning Jeff, page has your full rundown.", + "Morning Jeff, everything's on your dashboard.", + "Morning Jeff, click through for the details.", + "Morning Jeff, the link has today's stuff.", + "Morning Jeff, tap here for more.", + ]) + def test_a_line_that_names_the_link_is_redrafted(self, bad): + bad = bad + "\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + out, calls = _draft_returning(bad, GOOD) + assert out == GOOD + assert len(calls) == 2, "must redraft exactly once" + + def test_a_clean_line_is_not_redrafted(self): + out, calls = _draft_returning(GOOD) + assert out == GOOD + assert len(calls) == 1, "a clean line must not cost a second call" + + def test_the_redraft_is_told_what_it_did_wrong(self): + _, calls = _draft_returning(TAIL_BAD, GOOD) + assert "Check the page." in calls[1]["messages"][0]["content"] + + def test_it_gives_up_after_one_redraft(self): + """Two calls, not a loop — a stubborn model must not burn the budget.""" + out, calls = _draft_returning(TAIL_BAD, TAIL_BAD) + assert len(calls) == 2 + assert out, "a second violation still ships rather than failing the morning" + + def test_ordinary_words_are_not_false_positives(self): + out, calls = _draft_returning(GOOD) + assert len(calls) == 1 + assert not morning._NAMES_THE_LINK.search("Cool and clear, pages of rain later") + + +class TestLineDiscipline: + def _draft(self, text): + return _draft_returning(text)[0] + + def test_long_output_is_trimmed_on_a_line_boundary(self): + out = self._draft(GOOD + "".join(f"\n- NEWS: headline number {i} goes on a while" for i in range(30))) + assert len(out) <= morning.MORNING_LINE_MAX + assert out.splitlines()[-1].endswith("goes on a while"), "no half-lines" + + def test_blank_lines_go_but_the_bullets_stay_separate_lines(self): + out = self._draft("Easy one.\n\n- WEATHER: 71 and clear\n\n- COMMUTE: 34 min") + assert out == "Easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + + def test_a_paragraph_is_redrafted_into_bullets(self): + out, calls = _draft_returning("Cool 71 and clear, drive is 34 min.", GOOD) + assert out == GOOD and len(calls) == 2 + assert "not the format" in calls[1]["messages"][0]["content"] + + def test_nothing_loaded_means_a_plain_greeting_is_fine(self): + out, calls = _draft_returning("Morning Jeff.", payload={"city": "Kirkwood, MO"}) + assert out == "Morning Jeff." and len(calls) == 1 + + def test_the_bullets_survive_the_sms_cleaner(self): + from palmer.smstext import _sms_clean + assert _sms_clean(GOOD) == GOOD + assert _sms_clean("- first\n* second") == "first\nsecond" + + def test_empty_output_raises_so_the_caller_can_fall_back(self): + with pytest.raises(ValueError): + self._draft(" ") + + def test_meta_commentary_raises(self): + with pytest.raises(ValueError): + self._draft("Not sending the weather since they asked me to skip it.") + + def test_a_normal_line_passes_through_untouched(self): + assert self._draft(GOOD) == GOOD + + def test_the_drafter_strips_a_placeholder_before_returning(self): + assert self._draft(GOOD + " [link]") == GOOD + + def test_the_prompt_also_warns_against_placeholders(self): + assert "placeholder" in TestPrompt()._prompt()["messages"][0]["content"].lower() + + +class TestRequiredContent: + """Every user's morning text carries the same basics — weather, commute + when they have an address on file, and 1-2 opening highlights — before + the link. Nothing here checks the model's actual output (that would just + be re-testing the mock); it checks that the prompt tells the model which + of those are mandatory, based on what the payload actually has.""" + + def _required_block(self, payload): + body = _draft_returning("Cool and clear, 22 min in, and Mamele's just opened.", + payload=payload)[1][0]["messages"][0]["content"] + return body + + def test_weather_commute_and_opening_are_all_required_when_present(self): + payload = dict(PAYLOAD, opening=[{"title": "Mamele's", "subtitle": "new deli"}]) + body = self._required_block(payload) + assert "so every one of them must appear" in body + assert "today's weather" in body + assert "the commute" in body + assert "newly open" in body + + def test_commute_is_not_required_without_an_address(self): + payload = {k: v for k, v in PAYLOAD.items() if k != "traffic"} + body = self._required_block(payload) + assert "so every one of them must appear" in body + assert "the commute" not in body + + def test_opening_is_not_required_without_data(self): + payload = {k: v for k, v in PAYLOAD.items() if k != "headlines"} + assert "opening" not in payload + body = self._required_block(payload) + assert "newly open" not in body + + def test_no_structured_data_falls_back_to_a_plain_greeting_instruction(self): + body = self._required_block({}) + assert "plain greeting is correct" in body + assert "so every one of them must appear" not in body + + +class TestPrompt: + def _prompt(self): + return _draft_returning("Cool 71 and clear.")[1][0] + + def test_drafts_in_palmers_voice_not_a_second_persona(self): + """Every user-facing message goes through _build_system. A one-line + greeting is still a user-facing message.""" + assert self._prompt()["system"] == "sys" + + def test_drafts_on_sonnet_like_every_other_user_facing_message(self): + assert self._prompt()["model"] == morning.SONNET_MODEL + + def test_the_page_data_reaches_the_drafter(self): + body = self._prompt()["messages"][0]["content"] + assert "Starship static fire" in body and "34 min" in body + + def test_it_is_told_not_to_name_the_link(self): + body = self._prompt()["messages"][0]["content"].lower() + for word in ("link", "page", "dashboard", "click"): + assert word in body, f"the prompt must explicitly ban {word!r}" + + +class TestTheirTeamInTheMorning: + """A followed team's game rides in the morning update rather than in live + texts during the game. The digest carries it from the team's side, and the + REQUIRED list makes the drafter say it.""" + + GAME_LAST = {"id": "1", "league": "mlb", "state": "post", "detail": "Final", + "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 5}, + "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 2}} + GAME_TODAY = {"id": "2", "league": "mlb", "state": "pre", "detail": "7:15 PM CT", + "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 0}, + "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 0}} + ROW = {"team": "St. Louis Cardinals", "abbrev": "STL", "league": "mlb", + "last": GAME_LAST, "today": GAME_TODAY} + + def test_the_digest_states_result_and_next_game_from_their_side(self): + d = morning._payload_digest(dict(PAYLOAD, scores=[self.ROW])) + assert "Their team (St. Louis Cardinals): yesterday beat Chicago Cubs 5-2; today play Chicago Cubs, 7:15 PM CT" in d + + def test_a_row_with_only_a_result_says_only_that(self): + lines = morning.score_lines({"scores": [dict(self.ROW, today=None)]}) + assert lines == ["Their team (St. Louis Cardinals): yesterday beat Chicago Cubs 5-2"] + + def test_no_followed_team_adds_nothing(self): + assert morning.score_lines(PAYLOAD) == [] + assert "Their team" not in morning._payload_digest(PAYLOAD) + + def test_the_team_is_required_when_present(self): + body = _draft_returning("Cards won 5-2 last night, back at it at 7:15.", + payload=dict(PAYLOAD, scores=[self.ROW]))[1][0]["messages"][0]["content"] + assert "their team" in body and "so every one of them must appear" in body + + def test_the_team_is_not_required_without_a_game(self): + body = _draft_returning(GOOD, payload=PAYLOAD)[1][0]["messages"][0]["content"] + assert "their team" not in body + + +# ============================================================================ +# from test_morning_schedule.py +# ============================================================================ +# +# Tests for morning send-window logic and time parsing. Run: pytest tests/test_morning.py + +def _dt(hour, minute, tz="America/Chicago"): + return datetime(2026, 8, 3, hour, minute, tzinfo=ZoneInfo(tz)) + + +class TestParseMorningTime: + def test_default(self): + assert _parse_morning_time(DEFAULT_MORNING_TIME) == (7, 0) + + def test_custom(self): + assert _parse_morning_time("08:30") == (8, 30) + assert _parse_morning_time("21:15") == (21, 15) + + def test_invalid_falls_back_to_default(self): + for bad in (None, "", "notatime", "25:00", "08:99", "8", 700): + assert _parse_morning_time(bad) == (7, 0), bad + + +class TestSendWindow: + def test_before_time_no_send(self): + assert not _in_send_window(_dt(8, 29), "08:30") + + def test_exactly_at_time_sends(self): + assert _in_send_window(_dt(8, 30), "08:30") + + def test_within_catchup_sends(self): + assert _in_send_window(_dt(9, 45), "08:30") + assert _in_send_window(_dt(10, 29), "08:30") + + def test_after_catchup_no_send(self): + assert not _in_send_window(_dt(10, 30), "08:30") + assert not _in_send_window(_dt(21, 0), "08:30") + + def test_none_uses_default(self): + assert _in_send_window(_dt(7, 0), None) + assert not _in_send_window(_dt(6, 30), None) + + def test_custom_time(self): + assert _in_send_window(_dt(7, 0), "07:00") + assert not _in_send_window(_dt(6, 55), "07:00") + assert not _in_send_window(_dt(9, 5), "07:00") + + def test_other_timezone(self): + assert _in_send_window(_dt(8, 35, tz="America/New_York"), "08:30") + + def test_invalid_pref_falls_back_to_default(self): + assert _in_send_window(_dt(7, 0), "garbage") + assert not _in_send_window(_dt(6, 30), "garbage") + + +class TestLocalToday: + def test_valid_tz(self): + assert _local_today("America/Chicago") is not None + + def test_missing_or_bad_tz_falls_back(self): + """Fallback is the UTC date, not the runner's local date — comparing to + date.today() made this fail every evening west of UTC.""" + from datetime import datetime, timezone + utc_today = datetime.now(timezone.utc).date() + assert _local_today(None) == utc_today + assert _local_today("Not/AZone") == utc_today + + +class TestNormalizeHhmm: + def test_valid(self): + assert _normalize_hhmm("07:00") == "07:00" + assert _normalize_hhmm("7:05") == "07:05" + assert _normalize_hhmm("23:59") == "23:59" + assert _normalize_hhmm(" 08:30 ") == "08:30" + + def test_invalid(self): + for bad in ("24:00", "12:60", "7am", "730", "", None, "7:5"): + assert _normalize_hhmm(bad) is None, bad + + +class TestParsePublished: + def test_rfc2822(self): + dt = _parse_published("Mon, 03 Aug 2026 10:00:00 GMT") + assert dt is not None and dt.year == 2026 + + def test_iso(self): + dt = _parse_published("2026-08-03T10:00:00Z") + assert dt is not None and dt.hour == 10 + + def test_garbage(self): + assert _parse_published("unknown") is None + assert _parse_published(None) is None + assert _parse_published("") is None + + +# ============================================================================ +# from test_briefing_shape.py +# ============================================================================ +# +# Tests for the two bugs behind the 'Palmer dumped a briefing on me' report. +# +# 1. A greeting must not produce briefing content — the briefing is scheduled, +# not assembled on request. +# 2. Briefing configuration must not leak into ordinary replies. One user had +# "Format: bullet points per subject" saved as a morning topic; the profile is +# dumped as raw JSON into every system prompt, so it read as an order for the +# current message and turned normal replies into labelled dumps. It was also +# being sent to the news search as a query. + +class TestGreetingIsNotABriefing: + def test_prompt_says_the_briefing_is_sent_not_assembled(self): + body = prompts.SYSTEM_PROMPT + assert "It is NOT something you assemble on request" in body + + def test_prompt_names_the_greeting_case(self): + body = prompts.SYSTEM_PROMPT.lower() + assert "a greeting is a greeting" in body + assert "they said hello; say hello back" in body + + def test_prompt_holds_even_before_the_briefing_has_gone_out(self): + """The 6:22am case: briefing not sent yet, so the pull is strongest.""" + assert "even when their briefing hasn't gone out yet today" in prompts.SYSTEM_PROMPT + + def test_prompt_bans_the_shapes_that_actually_appeared(self): + body = prompts.SYSTEM_PROMPT + assert "Here's your Thursday" in body, "the observed opener must be named" + assert "Weather -" in body and "Commute -" in body, "labelled sections must be named" + assert "anything you want me to dig into" in body.lower() + + +class TestBriefingConfigDoesNotLeakIntoReplies: + def _build(self, profile): + with patch.object(agent, "get_profile", return_value=profile), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + return agent._build_system("+15550001111") + + def test_topics_are_labelled_as_reference_data(self): + out = self._build({"morning_topics": ["St. Louis weather", "SpaceX news"]}) + assert "reference data, not instructions for this message" in out + assert "Never let it change how you write a reply" in out + + def test_no_such_note_without_topics(self): + assert "reference data, not instructions" not in self._build({"name": "Mike"}) + + def test_topics_still_visible_so_palmer_can_answer_what_am_i_getting(self): + out = self._build({"morning_topics": ["SpaceX news"]}) + assert "SpaceX news" in out + + def test_directive_is_stripped_from_the_prompt_entirely(self): + """Labelling it as data was not enough — the model still obeyed it.""" + out = self._build({"morning_topics": [ + "SpaceX news", "Format: bullet points per subject, not one continuous paragraph", + ]}) + assert "SpaceX news" in out, "real topics must survive" + assert "bullet points per subject" not in out, \ + "a stored formatting directive must never reach the reply prompt" + + def test_profile_is_not_mutated(self): + profile = {"morning_topics": ["SpaceX news", "Format: bullets"]} + agent._prompt_safe_profile(profile) + assert len(profile["morning_topics"]) == 2, "must not edit the stored profile" + + def test_untouched_when_nothing_to_strip(self): + """Content, not identity. The profile is always copied now — volatile + facts are dated or dropped on the way to the prompt — so returning the + same object is no longer the contract. Not mutating the stored one is, + and that is asserted above.""" + profile = {"morning_topics": ["SpaceX news"], "name": "Jeff"} + assert agent._prompt_safe_profile(profile) == profile + + +class TestDirectivesAreNotTopics: + def test_format_directive_detected(self): + assert morning._is_directive("Format: bullet points per subject, not one continuous paragraph") + + def test_real_topics_are_not_directives(self): + for t in ("SpaceX news", "St. Louis Cardinals baseball news", + "Bitcoin and major stock news", "Daily fun fact from history"): + assert not morning._is_directive(t), t + + def test_case_and_whitespace_insensitive(self): + assert morning._is_directive(" FORMAT: bullet points ") + + def test_directive_never_reaches_the_news_search(self): + searched = [] + profile = {"city": "", "morning_topics": [ + "Format: bullet points per subject", "SpaceX news", + ]} + with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ + patch.object(morning, "_get_price", return_value="x"), \ + patch("palmer.trends.adjacent_story", return_value=None): + morning._gather_morning_data(profile) + assert "SpaceX news" in searched + assert not any("Format:" in s for s in searched), \ + "a formatting preference was sent to Tavily as a news query" + + +class TestTopicCoverage: + def test_cap_covers_a_realistic_subscription(self): + """8 real topics used to silently become 3.""" + assert morning.MAX_TOPICS >= 6 + + def test_all_topics_up_to_the_cap_are_pulled(self): + searched = [] + profile = {"city": "", "morning_topics": [f"topic {i}" for i in range(8)]} + with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ + patch.object(morning, "_get_price", return_value="x"), \ + patch("palmer.trends.adjacent_story", return_value=None): + morning._gather_morning_data(profile) + assert len(searched) == morning.MAX_TOPICS + + +class TestBriefingShapeRules: + def test_prompt_forbids_subject_labels(self): + import inspect + src = inspect.getsource(morning.generate_morning) + assert "Never label a line with its subject" in src + assert "Cardinals - lost 5-4" in src, "the observed bad shape should be the example" + + +class TestCommuteRoute: + """Palmer promised 'live drive time from Cedarbrook to Carondelet Plaza' but + morning.py only ever called get_city_traffic(city), which gives area-wide + conditions. The route tool existed; it just wasn't wired in.""" + + TOPIC = ("Daily commute traffic: 33 Cedarbrook Lane, Kirkwood MO 63122 " + "to 190 Carondelet Plaza, Clayton MO 63105") + + def test_parses_the_shape_users_actually_save(self): + assert morning._commute_route({"morning_topics": [self.TOPIC]}) == ( + "33 Cedarbrook Lane, Kirkwood MO 63122", "190 Carondelet Plaza, Clayton MO 63105") + + def test_structured_field_wins_over_topic_text(self): + route = morning._commute_route({ + "commute": {"origin": "1 Main St, Springfield", "destination": "2 Oak Ave, Shelbyville"}, + "morning_topics": [self.TOPIC], + }) + assert route == ("1 Main St, Springfield", "2 Oak Ave, Shelbyville") + + def test_no_route_when_none_saved(self): + assert morning._commute_route({"morning_topics": ["SpaceX news"]}) is None + + def test_plain_traffic_topic_is_not_a_route(self): + assert morning._commute_route({"morning_topics": ["St. Louis traffic"]}) is None + + def test_uses_route_not_city_when_available(self): + with patch.object(morning, "get_travel_time", return_value="22 minutes, 13 miles. 3 over normal.") as route, \ + patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ + patch.object(morning, "_weather_report", return_value="warm"), \ + patch.object(morning, "_topic_digest", return_value="x"), \ + patch("palmer.trends.adjacent_story", return_value=None): + out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) + route.assert_called_once() + city.assert_not_called() + assert any("22 minutes" in s for s in out) + + def test_falls_back_to_city_without_a_route(self): + with patch.object(morning, "get_travel_time") as route, \ + patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ + patch.object(morning, "_weather_report", return_value="warm"), \ + patch.object(morning, "_topic_digest", return_value="x"), \ + patch("palmer.trends.adjacent_story", return_value=None): + morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) + route.assert_not_called() + city.assert_called_once() + + def test_routing_failure_falls_back_instead_of_leaking_the_error(self): + """get_travel_time returns its errors as strings — those must never + reach a briefing as if they were traffic.""" + for failure in ("Couldn't find that starting address: '33 Cedarbrook'.", + "Routing failed for 'a' → 'b'.", + "Traffic API is not configured."): + with patch.object(morning, "get_travel_time", return_value=failure), \ + patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ + patch.object(morning, "_weather_report", return_value="warm"), \ + patch.object(morning, "_topic_digest", return_value="x"), \ + patch("palmer.trends.adjacent_story", return_value=None): + out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) + city.assert_called_once() + assert not any("Couldn't find" in s or "Routing failed" in s for s in out) + + def test_route_line_ok(self): + assert morning._route_line_ok("17 minutes, 13.7 miles. Basically free-flow.") + assert not morning._route_line_ok("") + assert not morning._route_line_ok(None) + assert not morning._route_line_ok("Need both an origin and destination address to route.") + + def test_commute_is_a_tool_not_an_extracted_field(self): + """set_commute stores coordinates and a leave time the extractor knows + nothing about, so the field left EXTRACT_PROMPT the way followed_teams + and shows did: a Haiku write of {origin, destination} would replace the + tool's dict and drop both.""" + from palmer import prompts + from palmer.tools_def import TOOLS + assert '"commute"' not in prompts.EXTRACT_PROMPT + assert "set_commute" in prompts.SYSTEM_PROMPT + assert any(t["name"] == "set_commute" for t in TOOLS) + + +# ============================================================================ +# from test_weather_city.py +# ============================================================================ +# +# The weather city: written where the user sets it, named where it was measured. +# +# A user in Culver City got three consecutive mornings of Los Angeles +# temperatures — 98, 100, 102 — against local highs of 88, 89, 90. weather.py was +# innocent throughout; it faithfully forecast whatever city it was handed. Two +# independent defects stacked: +# +# 1. WRITE. He set his weather location by saying "I want the weather updates to +# be specific to Culver City California". That routes to +# update_morning_briefing, which wrote the topic string and nothing else, so +# profile["city"] kept its older, broader value — and profile["city"] is the +# sole input to every weather pull Palmer makes. EXTRACT_PROMPT could not +# cover it: LOCATION PRECISION only writes city from a statement of residence +# or an explicit correction, and a weather preference is neither. +# +# 2. READ. The drafter was handed "Weather in Los Angeles: high 102" and still +# wrote "102 in Culver City today", reconciling the number against the +# Culver City strings all over the profile in its system prompt. Nothing +# stopped it: the line prompt's only data rule was about numbers. The text +# briefing has had the city rule since the beginning (see morning.py's +# "name the city the forecast is for") — the one-line path never inherited it. +# +# Defect 1 governs how often the data is wrong. Defect 2 governs what a wrong +# value can do: a number under the wrong city name is unfalsifiable from the +# message, where "102 in Los Angeles" is read as wrong in one second. Both are +# tested here, because fixing only the first leaves the next unenumerated write +# path free to produce the same confident lie. + +def _dispatch_block() -> str: + """The update_morning_briefing arm of the tool loop, as source.""" + import inspect + src = inspect.getsource(agent.get_reply) + return src.split('update_morning_briefing"')[1].split("elif b.name")[0] + + +class TestCityFromWeatherTopic: + def test_a_weather_topic_yields_its_city(self): + with patch("palmer.morning._infer_city_from_topics", return_value="Culver City, CA") as m: + assert agent._city_from_weather_topic("Culver City CA weather") == "Culver City, CA" + assert m.call_args[0][0] == ["Culver City CA weather"] + + def test_forecast_and_temperature_phrasings_also_count(self): + with patch("palmer.morning._infer_city_from_topics", return_value="Woodland Hills, CA"): + for t in ("Woodland Hills CA weather forecast", "Denver temperature"): + assert agent._city_from_weather_topic(t) == "Woodland Hills, CA" + + def test_a_non_weather_topic_never_pays_for_a_lookup(self): + """This runs on the write path, but a model call per added topic is + still worth avoiding — and a news topic must never move the city.""" + with patch("palmer.llm.client") as client: + for t in ("AI news", "US politics", "Bitcoin price", "SPCX stock price"): + assert agent._city_from_weather_topic(t) is None + client.messages.create.assert_not_called() + + def test_empty_input_is_safe(self): + assert agent._city_from_weather_topic("") is None + assert agent._city_from_weather_topic(None) is None + + def test_an_unresolvable_weather_topic_leaves_the_city_alone(self): + with patch("palmer.morning._infer_city_from_topics", return_value=None): + assert agent._city_from_weather_topic("weather") is None + + +class TestTheAddPathWritesTheCity: + """Where the user sets their weather location is where it has to be saved.""" + + def test_the_add_path_derives_a_city(self): + assert "_city_from_weather_topic" in _dispatch_block() + + def test_the_derived_city_is_actually_written(self): + block = _dispatch_block() + assert '"city"' in block, "deriving the city and not saving it is the original bug" + + def test_a_moved_city_expires_the_cached_forecast(self): + """The page caches weather for 10 minutes. Without expiring it, the + correction is followed by the old city's forecast under the new name — + the precise pairing that made this a lie rather than a stale number.""" + block = _dispatch_block() + assert "weather" in block and "invalidate" in block + + def test_it_does_not_move_their_timezone(self): + """city doubles as the timezone source, but timezone is only derived + when absent. Re-deriving here would shift the hour their morning + arrives as a side effect of correcting a forecast.""" + assert "timezone" not in _dispatch_block() + + +class TestTheForecastNamesWhereItWasMeasured: + """`resolved` comes back from the same geocode that produced the numbers, + so the name and the number cannot disagree.""" + + DRIFTED = { + # profile already corrected; the 10-minute weather stamp has not lapsed + "city": "Culver City", + "weather": {"resolved": "Los Angeles, California", "temp_now": 74.5, + "high": 101.9, "low": 71.5, "description": "clear sky"}, + } + + def test_the_digest_names_the_measured_city_not_the_profiles(self): + d = morning._payload_digest(self.DRIFTED) + assert "Los Angeles, California" in d + assert "Culver City" not in d, "a Los Angeles number must not carry the Culver City name" + + def test_the_temperature_still_rides_with_it(self): + assert "high 102" in morning._payload_digest(self.DRIFTED) + + def test_it_falls_back_to_the_profile_city_when_unresolved(self): + d = morning._payload_digest( + {"city": "Kirkwood, MO", "weather": {"high": 88, "low": 64, "description": "clear"}}) + assert "Kirkwood, MO" in d + + def test_neither_name_leaves_the_line_cityless(self): + d = morning._payload_digest({"weather": {"high": 88, "low": 64, "description": "clear"}}) + assert "their city" in d + + +class TestTheLineIsToldNotToRenameTheCity: + def test_the_prompt_carries_the_city_rule(self): + """The text briefing has always had this rule; the one-line path that + replaced it as the daily send did not inherit it.""" + calls = [] + + def _create(**kw): + calls.append(kw) + return type("R", (), {"content": [type("B", (), {"text": "Cool 71 and clear."})()]})() + + # Patch where the function lives, not where it was defined — morning + # binds `client` at import (CLAUDE.md, "Patching in tests follows the + # code, not the name"). A dead target here would make a real API call. + with patch.object(morning, "get_profile", return_value={"timezone": "America/Chicago"}), \ + patch.object(morning, "_build_system", return_value="sys"), \ + patch.object(morning, "_recent_assistant_texts", return_value=[]), \ + patch.object(morning.client.messages, "create", side_effect=_create): + morning.generate_morning_line("+1555", TestTheForecastNamesWhereItWasMeasured.DRIFTED) + body = calls[0]["messages"][0]["content"].lower() + assert "name the city" in body + assert "data wins" in body + + +# ============================================================================ +# from test_repetition.py +# ============================================================================ +# +# Repetition, and the two opposite remedies it needs. +# +# Measured across every message Palmer has sent: 39 near-duplicate pairs for one +# user, 11 for another. They were not one problem. +# +# SUPPRESSION — an unprompted message repeating one already sent. One user got the +# identical followup twice, verbatim, because `_is_duplicate_subject` looked back +# six hours while the followup job runs every four and the subject stayed live for +# days. Another got "Here you go - " three times, word for word. +# +# VARIATION — a scheduled message the user DID ask for, said the same way every +# time: "Morning Alex - 103 today in Cedar Falls", "106 in Cedar Falls +# today, Alex", "111 today in Cedar Falls, Alex". Suppressing those would be +# wrong; they asked for a daily briefing. Only the phrasing may not repeat. And +# token overlap cannot see it — those score 0.23 against each other. +# +# LEAKAGE — a third thing found on the way: Palmer narrating its own filtering to +# the reader, in the third person, about her. morning.py had a guard; the four +# other senders never ran it. +# +# The corpora here are real messages. All offline. + +REPEAT_MORNINGS = [ + "Morning Alex - 103 today in Cedar Falls, stay inside if you can.", + "106 in Cedar Falls today, Alex - hottest it's been all week.", + "111 today in Cedar Falls, Alex - and there's actually a chance of thunderstorms.", + "110 in Cedar Falls today, so outdoor plans need a rethink.", +] +FRESH_LINES = [ + "Courtney Barnett plays the Hollywood Palladium Friday if you want a reason to get out.", + "Bitcoin's up 3.1% overnight - quiet week so that move's worth a look.", + "85 and sunny in Kirkwood, commute's clean at 17 minutes.", +] + + +class TestSuppressingVerbatimRepeats: + def test_the_identical_followup_is_caught(self): + line = "yo how'd practice look today? hurts moving like they said?" + assert guards.near_duplicate(line, [line]) == line + + def test_the_repeated_link_reply_is_caught(self): + a = "Here you go -\n\nhttps://palmer-app.example/h/abc" + b = "Here you go -\n\nhttps://palmer-app.example/h/abc" + assert guards.near_duplicate(a, [b]) + + def test_the_url_is_not_what_makes_them_similar(self): + """Two different sentences carrying the same page link are not repeats — + otherwise every message that ends in their URL reads as identical.""" + a = "Muse plays the Hollywood Bowl Sunday. https://x.example/h/abc" + b = "Bitcoin's up 3% overnight. https://x.example/h/abc" + assert guards.near_duplicate(a, [b]) is None + + def test_genuinely_different_messages_pass(self): + for i, line in enumerate(FRESH_LINES): + others = FRESH_LINES[:i] + FRESH_LINES[i + 1:] + assert guards.near_duplicate(line, others) is None, line + + def test_the_lexical_check_runs_before_any_model_call(self): + """The point is that a verbatim repeat costs nothing to catch, so it can + look back three days where the semantic check cannot afford to.""" + import inspect + from palmer import userprofile + src = inspect.getsource(userprofile._is_duplicate_subject) + assert src.index("near_duplicate") < src.index("client.messages.create") + assert userprofile.VERBATIM_WINDOW_HOURS > 24 + + def test_no_history_is_not_a_duplicate(self): + assert guards.near_duplicate("anything", []) is None + assert guards.near_duplicate("", ["anything"]) is None + + +class TestVaryingAScheduledMessage: + """The content is supposed to recur. The opening is not.""" + + def test_the_repeated_mornings_share_an_opening_shape(self): + shapes = {guards.opening_shape(m) for m in REPEAT_MORNINGS[1:]} + assert len(shapes) == 1, f"these three open identically: {shapes}" + + def test_a_repeat_is_detected_against_recent_sends(self): + assert guards.repeats_opening(REPEAT_MORNINGS[3], REPEAT_MORNINGS[:3]) + + def test_numbers_do_not_disguise_a_repeat(self): + """103 / 106 / 111 made every day look unique to a token comparison.""" + assert guards.similarity(REPEAT_MORNINGS[1], REPEAT_MORNINGS[2]) < 0.4 + assert guards.repeats_opening(REPEAT_MORNINGS[1], [REPEAT_MORNINGS[2]]) + + def test_a_genuinely_different_opening_is_left_alone(self): + for line in FRESH_LINES: + assert guards.repeats_opening(line, REPEAT_MORNINGS) is None, line + + def test_leading_with_something_else_clears_it(self): + """The redraft asks for the same facts starting somewhere else.""" + recast = "Courtney Barnett's at the Hollywood Palladium Friday, and it's 110 in Cedar Falls." + assert guards.repeats_opening(recast, REPEAT_MORNINGS) is None + + def test_the_morning_line_redrafts_on_a_repeat(self): + import inspect + from palmer import morning + src = inspect.getsource(morning.generate_morning_line) + assert "repeats_opening" in src + # Split across source lines in the literal, so match a fragment. + assert "number identical" in src, "a recast must not move the facts" + + +class TestDeliberationNeverShips: + LEAKED = [ + "Both of these fall into the crime/dark content category they explicitly " + "asked to avoid. Skipping.", + "This one's in the crime/dark content bucket they asked to avoid. Skipping it.", + ] + FINE = [ + "Muse plays the Hollywood Bowl Sunday if you need a reason to get out.", + "They beat the Pirates 4-1 last night - Mathews got his first career win.", + "You asked me to watch that fare - it's down to $668.", + "90 in Culver City today, low 70, barely a rain chance.", + # These four were BLOCKED by the either-signal version. Because + # send_sms returns False and main.py answers a falsy send with + # FALLBACK_SMS, the user got "something went sideways on my end, try + # again" in place of a perfectly good reply. Agreeing to stop doing + # something is not a leak, and neither is news about someone else. + "got it, not sending those anymore", + "noted - won't send you the crime stuff again", + "they said the deal closes Friday", + "they asked for a recount and the board agreed", + ] + + def test_the_real_leaks_are_caught(self): + for text in self.LEAKED: + assert guards.leaks_deliberation(text), text + + def test_ordinary_messages_survive(self): + """"They beat the Pirates" is a sentence about a baseball team, and + "you asked me to watch that fare" is Palmer talking TO someone.""" + for text in self.FINE: + assert not guards.leaks_deliberation(text), text + + def test_agreeing_to_stop_is_not_a_leak(self): + """The distinction the guard has to make: "not sending those anymore" is + a commitment TO the reader; "not sending, doesn't meet the threshold" is + Palmer explaining its plumbing to them.""" + assert not guards.leaks_deliberation("sure, not sending those anymore") + assert guards.leaks_deliberation("Not sending - it doesn't meet the threshold.") + + def test_news_about_a_third_party_survives(self): + """"said" is deliberately not an intent verb here: "they said the deal + closes Friday" is the sort of sentence Palmer exists to send.""" + for text in ("they said the deal closes Friday", + "they wanted a bigger deal and walked", + "she told me they prefer the early show"): + assert not guards.leaks_deliberation(text), text + + def test_naming_the_reader_as_the_user_is_damning_alone(self): + """Nobody texting a friend calls them "the user".""" + assert guards.leaks_deliberation("the user prefers shorter updates") + + def test_internal_machinery_is_damning_alone(self): + for text in ("scored below the bar so no alert needed", + "this one was filtered out", + "suppressing that one"): + assert guards.leaks_deliberation(text), text + + def test_paraphrase_does_not_escape_it(self): + """The guard it replaces matched fixed phrases, so the model wrote + around them — "they EXPLICITLY asked" missed a rule looking for + "they asked".""" + for text in ("the user specified no sports so leaving that out", + "This one doesn't meet the threshold, not sending.", + "They said they prefer lighter stuff, so I'll skip it."): + assert guards.leaks_deliberation(text), text + + def test_it_is_blocked_at_the_one_place_everything_passes_through(self): + """morning.py had a guard for this and four other senders never ran it.""" + import inspect + from palmer import sms_util + assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) + + def test_blocking_returns_false_rather_than_sending_something_else(self): + from palmer import sms_util + with patch.object(sms_util, "_twilio") as tw: + sent = sms_util.send_sms("+1555", self.LEAKED[0]) + assert sent is False + tw.messages.create.assert_not_called(), "nothing may go out, not even a fallback" + + +class TestADeliberationLeakInAReplyIsRedraftedNotDropped: + """send_sms blocks a leak outright, and for an unprompted message that is + exactly right — every real violation was a drafter announcing it had decided + NOT to send something, so doing that silently is what it was trying to do. + + On a reply it is the wrong trade. The user is waiting on an answer, and a + block there means main.py's falsy-send path hands them FALLBACK_SMS. So + _finalize redrafts first, and the send_sms block stays behind it.""" + + def _finalize(self, first, *retries): + from palmer import agent + calls = [] + + def _create(**kw): + calls.append(kw) + text = retries[min(len(calls) - 1, len(retries) - 1)] if retries else first + return MagicMock(content=[MagicMock(text=text)]) + + with patch.object(agent.client.messages, "create", side_effect=_create): + out, _ = agent._finalize(first, "sys", [{"role": "user", "content": "hi"}], None) + return out, calls + + def test_a_leak_is_redrafted_once(self): + out, calls = self._finalize( + "This one doesn't meet the threshold, not sending.", + "nothing worth flagging today") + assert "threshold" not in out + assert len(calls) == 1 + + def test_a_clean_reply_still_costs_nothing(self): + out, calls = self._finalize("sure, not sending those anymore") + assert calls == [] + assert out == "sure, not sending those anymore" + + def test_a_failed_redraft_keeps_the_original_rather_than_going_silent(self): + from palmer import agent + leak = "This one doesn't meet the threshold, not sending." + with patch.object(agent.client.messages, "create", side_effect=RuntimeError("down")): + out, _ = agent._finalize(leak, "sys", [], None) + assert out == leak, "send_sms is the backstop; _finalize must not blank it" + + def test_the_correction_tells_it_what_to_do_instead(self): + assert "TO them" in guards.DELIBERATION_CORRECTION + + def test_the_send_sms_block_is_still_there(self): + """The chokepoint stays — proactive senders never reach _finalize.""" + import inspect + from palmer import sms_util + assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) diff --git a/tests/test_morning_link.py b/tests/test_morning_link.py deleted file mode 100644 index d31fadf..0000000 --- a/tests/test_morning_link.py +++ /dev/null @@ -1,349 +0,0 @@ -"""The morning update is a link plus one line. - -The briefing itself lives on the user's page; the text above it exists to say -why today is worth a tap. Two properties are load-bearing and tested here: - -1. The URL is LAST in the message and nothing follows it. Message apps only - render the rich preview when the link sits at a boundary, and that preview - is the reason this shape works at all. -2. Every failure in the page path falls back to the full text briefing. A user - never gets a link to an empty page, and never gets silence. -""" -from unittest.mock import patch - -import pytest - -from palmer import morning - - -PAYLOAD = { - "city": "Kirkwood, MO", - "weather": {"temp_now": 71, "high": 88, "low": 64, "description": "clear", "rain_pct": 10}, - "traffic": {"live_min": 34, "delay_min": 9}, - "prices": [{"label": "BTC", "pct_24h": -4.2}], - "headlines": [{"title": "Starship static fire slips a week", "topic": "SpaceX news"}], -} - -URL = "https://palmer.example.com/h/AbC123xyz" - - -class TestDigest: - def test_carries_every_section_as_plain_lines(self): - d = morning._payload_digest(PAYLOAD) - assert "Kirkwood, MO" in d and "clear" in d - assert "34 min" in d and "9 min slower" in d - assert "BTC: -4.2%" in d - assert "Starship static fire" in d - - def test_normal_commute_is_not_dressed_up_as_a_delay(self): - d = morning._payload_digest({"traffic": {"live_min": 20, "delay_min": 0}}) - assert "normal" in d and "slower" not in d - - def test_empty_payload_is_falsy_so_callers_can_gate_on_it(self): - assert morning._payload_digest({}) == "" - assert not morning._payload_digest({"weather": {}, "prices": [], "headlines": []}) - - -class TestComposeShape: - def _compose(self, line="Cool 71 and clear, but your drive is 9 min heavier than usual."): - with patch("palmer.home.ensure_fresh", return_value=URL), \ - patch("palmer.home.load", return_value=PAYLOAD), \ - patch("palmer.home.home_token", return_value="tok"), \ - patch.object(morning, "generate_morning_line", return_value=line), \ - patch.object(morning, "generate_morning") as full: - msg, carries = morning._compose_morning("+1555") - return msg, carries, full - - def test_url_is_last_with_nothing_after_it(self): - msg, carries, full = self._compose() - assert carries is True - assert msg.endswith(URL), "a trailing character kills the link preview" - full.assert_not_called() - - def test_message_carries_exactly_one_url(self): - msg, _, _ = self._compose() - assert msg.count("http") == 1, "a second URL suppresses the preview" - - def test_the_line_survives_ahead_of_the_link(self): - msg, _, _ = self._compose(line="Bitcoin is off 4.2% overnight.") - assert msg.startswith("Bitcoin is off 4.2% overnight.\n\n") - - def test_it_is_one_message_not_two(self): - """The briefing used to be its own text with the link chasing it.""" - msg, _, _ = self._compose() - assert len(msg) < 900, "must fit one send, not trigger the chunker" - - -class TestFallbacks: - def _compose_with(self, **patches): - defaults = {"ensure_fresh": URL, "load": PAYLOAD, "home_token": "tok"} - defaults.update(patches) - with patch("palmer.home.ensure_fresh", return_value=defaults["ensure_fresh"]), \ - patch("palmer.home.load", return_value=defaults["load"]), \ - patch("palmer.home.home_token", return_value=defaults["home_token"]), \ - patch.object(morning, "generate_morning", return_value="the long briefing"), \ - patch.object(morning, "generate_morning_line", - side_effect=defaults.get("line_error") or (lambda *a, **k: "a line")): - return morning._compose_morning("+1555") - - def test_missing_app_url_falls_back_to_the_text_briefing(self): - msg, carries = self._compose_with(ensure_fresh="/h/tok") - assert msg == "the long briefing" and carries is False - - def test_empty_page_falls_back_rather_than_linking_to_nothing(self): - msg, carries = self._compose_with(load={}) - assert msg == "the long briefing" and carries is False - - def test_missing_page_falls_back(self): - msg, carries = self._compose_with(load=None) - assert msg == "the long briefing" and carries is False - - def test_a_failed_line_draft_falls_back(self): - msg, carries = self._compose_with(line_error=RuntimeError("sonnet down")) - assert msg == "the long briefing" and carries is False - - def test_the_fallback_never_carries_a_link(self): - """carries_link gates the /sms-status shorten-and-retry, which would - happily cut a URL in half. A text briefing must keep that retry.""" - for kw in ({"ensure_fresh": "/h/tok"}, {"load": {}}, - {"line_error": RuntimeError("x")}): - assert self._compose_with(**kw)[1] is False - - -class TestLinkPlaceholder: - """The drafter knows a link is coming and reaches for a stand-in. Left in, - "[link]" ships as literal text sitting next to the real URL.""" - - @pytest.mark.parametrize("raw", [ - "Arenado to the Dodgers. [link]", - "Arenado to the Dodgers. (url)", - "Arenado to the Dodgers. ", - "Arenado to the Dodgers. [Link]", - "Arenado to the Dodgers. (page)", - "Arenado to the Dodgers. [dashboard]", - ]) - def test_placeholders_are_stripped(self, raw): - assert morning._strip_link_placeholder(raw) == "Arenado to the Dodgers." - - def test_an_invented_url_is_stripped(self): - """A URL Palmer made up is worse than an awkward sentence — and it - would also break the preview by making this the second link.""" - out = morning._strip_link_placeholder("Big day https://made.up/thing") - assert "http" not in out - - def test_a_dangling_separator_goes_with_it(self): - assert morning._strip_link_placeholder("Rain all day -") == "Rain all day" - - def test_a_clean_line_is_untouched(self): - line = "Cool 71 and clear, and your drive is 9 min heavier than usual." - assert morning._strip_link_placeholder(line) == line - - def test_ordinary_brackets_survive(self): - line = "Cards lost (again)." - assert morning._strip_link_placeholder(line) == line - - -class _Block: - def __init__(self, t): self.text = t - - -class _Resp: - def __init__(self, t): self.content = [_Block(t)] - - -def _draft_returning(*texts, payload=None): - """Run the drafter against a scripted sequence of model outputs.""" - calls = [] - - def _create(**kw): - calls.append(kw) - return _Resp(texts[min(len(calls) - 1, len(texts) - 1)]) - - with patch.object(morning, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch.object(morning, "_build_system", return_value="sys"), \ - patch.object(morning, "_recent_assistant_texts", return_value=[]), \ - patch.object(morning.client.messages, "create", side_effect=_create): - return morning.generate_morning_line("+1555", payload if payload is not None else PAYLOAD), calls - - -GOOD = ""+"Morning Jeff, easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min"+"" -TAIL_BAD = "Check the page.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" - - -class TestNamingTheLink: - """"page has your full rundown" turns a text from a friend into a push - notification. The prompt asks for this; the code enforces it.""" - - @pytest.mark.parametrize("bad", [ - "Morning Jeff, page has your full rundown.", - "Morning Jeff, everything's on your dashboard.", - "Morning Jeff, click through for the details.", - "Morning Jeff, the link has today's stuff.", - "Morning Jeff, tap here for more.", - ]) - def test_a_line_that_names_the_link_is_redrafted(self, bad): - bad = bad + "\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" - out, calls = _draft_returning(bad, GOOD) - assert out == GOOD - assert len(calls) == 2, "must redraft exactly once" - - def test_a_clean_line_is_not_redrafted(self): - out, calls = _draft_returning(GOOD) - assert out == GOOD - assert len(calls) == 1, "a clean line must not cost a second call" - - def test_the_redraft_is_told_what_it_did_wrong(self): - _, calls = _draft_returning(TAIL_BAD, GOOD) - assert "Check the page." in calls[1]["messages"][0]["content"] - - def test_it_gives_up_after_one_redraft(self): - """Two calls, not a loop — a stubborn model must not burn the budget.""" - out, calls = _draft_returning(TAIL_BAD, TAIL_BAD) - assert len(calls) == 2 - assert out, "a second violation still ships rather than failing the morning" - - def test_ordinary_words_are_not_false_positives(self): - out, calls = _draft_returning(GOOD) - assert len(calls) == 1 - assert not morning._NAMES_THE_LINK.search("Cool and clear, pages of rain later") - - -class TestLineDiscipline: - def _draft(self, text): - return _draft_returning(text)[0] - - def test_long_output_is_trimmed_on_a_line_boundary(self): - out = self._draft(GOOD + "".join(f"\n- NEWS: headline number {i} goes on a while" for i in range(30))) - assert len(out) <= morning.MORNING_LINE_MAX - assert out.splitlines()[-1].endswith("goes on a while"), "no half-lines" - - def test_blank_lines_go_but_the_bullets_stay_separate_lines(self): - out = self._draft("Easy one.\n\n- WEATHER: 71 and clear\n\n- COMMUTE: 34 min") - assert out == "Easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" - - def test_a_paragraph_is_redrafted_into_bullets(self): - out, calls = _draft_returning("Cool 71 and clear, drive is 34 min.", GOOD) - assert out == GOOD and len(calls) == 2 - assert "not the format" in calls[1]["messages"][0]["content"] - - def test_nothing_loaded_means_a_plain_greeting_is_fine(self): - out, calls = _draft_returning("Morning Jeff.", payload={"city": "Kirkwood, MO"}) - assert out == "Morning Jeff." and len(calls) == 1 - - def test_the_bullets_survive_the_sms_cleaner(self): - from palmer.smstext import _sms_clean - assert _sms_clean(GOOD) == GOOD - assert _sms_clean("- first\n* second") == "first\nsecond" - - def test_empty_output_raises_so_the_caller_can_fall_back(self): - with pytest.raises(ValueError): - self._draft(" ") - - def test_meta_commentary_raises(self): - with pytest.raises(ValueError): - self._draft("Not sending the weather since they asked me to skip it.") - - def test_a_normal_line_passes_through_untouched(self): - assert self._draft(GOOD) == GOOD - - def test_the_drafter_strips_a_placeholder_before_returning(self): - assert self._draft(GOOD + " [link]") == GOOD - - def test_the_prompt_also_warns_against_placeholders(self): - assert "placeholder" in TestPrompt()._prompt()["messages"][0]["content"].lower() - - -class TestRequiredContent: - """Every user's morning text carries the same basics — weather, commute - when they have an address on file, and 1-2 opening highlights — before - the link. Nothing here checks the model's actual output (that would just - be re-testing the mock); it checks that the prompt tells the model which - of those are mandatory, based on what the payload actually has.""" - - def _required_block(self, payload): - body = _draft_returning("Cool and clear, 22 min in, and Mamele's just opened.", - payload=payload)[1][0]["messages"][0]["content"] - return body - - def test_weather_commute_and_opening_are_all_required_when_present(self): - payload = dict(PAYLOAD, opening=[{"title": "Mamele's", "subtitle": "new deli"}]) - body = self._required_block(payload) - assert "so every one of them must appear" in body - assert "today's weather" in body - assert "the commute" in body - assert "newly open" in body - - def test_commute_is_not_required_without_an_address(self): - payload = {k: v for k, v in PAYLOAD.items() if k != "traffic"} - body = self._required_block(payload) - assert "so every one of them must appear" in body - assert "the commute" not in body - - def test_opening_is_not_required_without_data(self): - payload = {k: v for k, v in PAYLOAD.items() if k != "headlines"} - assert "opening" not in payload - body = self._required_block(payload) - assert "newly open" not in body - - def test_no_structured_data_falls_back_to_a_plain_greeting_instruction(self): - body = self._required_block({}) - assert "plain greeting is correct" in body - assert "so every one of them must appear" not in body - - -class TestPrompt: - def _prompt(self): - return _draft_returning("Cool 71 and clear.")[1][0] - - def test_drafts_in_palmers_voice_not_a_second_persona(self): - """Every user-facing message goes through _build_system. A one-line - greeting is still a user-facing message.""" - assert self._prompt()["system"] == "sys" - - def test_drafts_on_sonnet_like_every_other_user_facing_message(self): - assert self._prompt()["model"] == morning.SONNET_MODEL - - def test_the_page_data_reaches_the_drafter(self): - body = self._prompt()["messages"][0]["content"] - assert "Starship static fire" in body and "34 min" in body - - def test_it_is_told_not_to_name_the_link(self): - body = self._prompt()["messages"][0]["content"].lower() - for word in ("link", "page", "dashboard", "click"): - assert word in body, f"the prompt must explicitly ban {word!r}" - - -class TestTheirTeamInTheMorning: - """A followed team's game rides in the morning update rather than in live - texts during the game. The digest carries it from the team's side, and the - REQUIRED list makes the drafter say it.""" - - GAME_LAST = {"id": "1", "league": "mlb", "state": "post", "detail": "Final", - "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 5}, - "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 2}} - GAME_TODAY = {"id": "2", "league": "mlb", "state": "pre", "detail": "7:15 PM CT", - "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 0}, - "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 0}} - ROW = {"team": "St. Louis Cardinals", "abbrev": "STL", "league": "mlb", - "last": GAME_LAST, "today": GAME_TODAY} - - def test_the_digest_states_result_and_next_game_from_their_side(self): - d = morning._payload_digest(dict(PAYLOAD, scores=[self.ROW])) - assert "Their team (St. Louis Cardinals): yesterday beat Chicago Cubs 5-2; today play Chicago Cubs, 7:15 PM CT" in d - - def test_a_row_with_only_a_result_says_only_that(self): - lines = morning.score_lines({"scores": [dict(self.ROW, today=None)]}) - assert lines == ["Their team (St. Louis Cardinals): yesterday beat Chicago Cubs 5-2"] - - def test_no_followed_team_adds_nothing(self): - assert morning.score_lines(PAYLOAD) == [] - assert "Their team" not in morning._payload_digest(PAYLOAD) - - def test_the_team_is_required_when_present(self): - body = _draft_returning("Cards won 5-2 last night, back at it at 7:15.", - payload=dict(PAYLOAD, scores=[self.ROW]))[1][0]["messages"][0]["content"] - assert "their team" in body and "so every one of them must appear" in body - - def test_the_team_is_not_required_without_a_game(self): - body = _draft_returning(GOOD, payload=PAYLOAD)[1][0]["messages"][0]["content"] - assert "their team" not in body diff --git a/tests/test_morning_schedule.py b/tests/test_morning_schedule.py deleted file mode 100644 index 7d8c309..0000000 --- a/tests/test_morning_schedule.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Tests for morning send-window logic and time parsing. Run: pytest test_morning_schedule.py""" -from dotenv import load_dotenv -load_dotenv() - -from datetime import datetime -from zoneinfo import ZoneInfo - -from palmer.morning import _in_send_window, _parse_morning_time, _local_today, DEFAULT_MORNING_TIME -from palmer.smstext import _normalize_hhmm, _parse_published - - -def _dt(hour, minute, tz="America/Chicago"): - return datetime(2026, 8, 3, hour, minute, tzinfo=ZoneInfo(tz)) - - -class TestParseMorningTime: - def test_default(self): - assert _parse_morning_time(DEFAULT_MORNING_TIME) == (7, 0) - - def test_custom(self): - assert _parse_morning_time("08:30") == (8, 30) - assert _parse_morning_time("21:15") == (21, 15) - - def test_invalid_falls_back_to_default(self): - for bad in (None, "", "notatime", "25:00", "08:99", "8", 700): - assert _parse_morning_time(bad) == (7, 0), bad - - -class TestSendWindow: - def test_before_time_no_send(self): - assert not _in_send_window(_dt(8, 29), "08:30") - - def test_exactly_at_time_sends(self): - assert _in_send_window(_dt(8, 30), "08:30") - - def test_within_catchup_sends(self): - assert _in_send_window(_dt(9, 45), "08:30") - assert _in_send_window(_dt(10, 29), "08:30") - - def test_after_catchup_no_send(self): - assert not _in_send_window(_dt(10, 30), "08:30") - assert not _in_send_window(_dt(21, 0), "08:30") - - def test_none_uses_default(self): - assert _in_send_window(_dt(7, 0), None) - assert not _in_send_window(_dt(6, 30), None) - - def test_custom_time(self): - assert _in_send_window(_dt(7, 0), "07:00") - assert not _in_send_window(_dt(6, 55), "07:00") - assert not _in_send_window(_dt(9, 5), "07:00") - - def test_other_timezone(self): - assert _in_send_window(_dt(8, 35, tz="America/New_York"), "08:30") - - def test_invalid_pref_falls_back_to_default(self): - assert _in_send_window(_dt(7, 0), "garbage") - assert not _in_send_window(_dt(6, 30), "garbage") - - -class TestLocalToday: - def test_valid_tz(self): - assert _local_today("America/Chicago") is not None - - def test_missing_or_bad_tz_falls_back(self): - """Fallback is the UTC date, not the runner's local date — comparing to - date.today() made this fail every evening west of UTC.""" - from datetime import datetime, timezone - utc_today = datetime.now(timezone.utc).date() - assert _local_today(None) == utc_today - assert _local_today("Not/AZone") == utc_today - - -class TestNormalizeHhmm: - def test_valid(self): - assert _normalize_hhmm("07:00") == "07:00" - assert _normalize_hhmm("7:05") == "07:05" - assert _normalize_hhmm("23:59") == "23:59" - assert _normalize_hhmm(" 08:30 ") == "08:30" - - def test_invalid(self): - for bad in ("24:00", "12:60", "7am", "730", "", None, "7:5"): - assert _normalize_hhmm(bad) is None, bad - - -class TestParsePublished: - def test_rfc2822(self): - dt = _parse_published("Mon, 03 Aug 2026 10:00:00 GMT") - assert dt is not None and dt.year == 2026 - - def test_iso(self): - dt = _parse_published("2026-08-03T10:00:00Z") - assert dt is not None and dt.hour == 10 - - def test_garbage(self): - assert _parse_published("unknown") is None - assert _parse_published(None) is None - assert _parse_published("") is None diff --git a/tests/test_my_page_tool.py b/tests/test_my_page_tool.py deleted file mode 100644 index 1ee27c5..0000000 --- a/tests/test_my_page_tool.py +++ /dev/null @@ -1,282 +0,0 @@ -"""get_my_page — Palmer hands over the user's link mid-conversation. - -The page is only useful if it can be asked for. Before this, the URL went out -once a day with the morning update and there was no way to get it back short of -scrolling the thread. - -The tool returns the URL and the model writes the sentence around it, so the -tests that matter are: the tool exists and is routed, dispatch returns a live -URL rather than a stale or invented one, and the no-APP_URL case tells the model -to shut up about the page instead of promising a link it can't send. -""" -from unittest.mock import patch - - -from palmer import agent -from palmer import prompts -from palmer.tools_def import TOOLS - -URL = "https://palmer.example.com/h/AbC123xyz" - - -def _tool(name): - return next((t for t in TOOLS if t["name"] == name), None) - - -class TestSchema: - def test_the_tool_exists(self): - assert _tool("get_my_page") is not None - - def test_it_takes_no_arguments(self): - """The caller is the user. There is nothing to pass and nothing to - get wrong — in particular no phone number the model could invent.""" - schema = _tool("get_my_page")["input_schema"] - assert schema["properties"] == {} and schema["required"] == [] - - def test_the_description_covers_how_people_actually_ask(self): - d = _tool("get_my_page")["description"].lower() - for phrase in ("send me my page", "link", "dashboard", "resend"): - assert phrase in d - - def test_the_description_pins_the_url_to_the_end(self): - d = _tool("get_my_page")["description"].lower() - assert "end" in d and "preview" in d - - def test_it_is_routed_in_the_system_prompt(self): - """Tool routing is strict in this codebase — a tool the prompt does not - name is a tool the model will not reliably reach for.""" - assert "get_my_page" in prompts.SYSTEM_PROMPT - - def test_the_prompt_forbids_typing_a_url_from_memory(self): - block = prompts.SYSTEM_PROMPT.split("get_my_page:")[1].split("\n")[0].lower() - assert "never type" in block or "from memory" in block - - -class _Block: - def __init__(self, **kw): - self.__dict__.update(kw) - - -class _Resp: - def __init__(self, content, stop_reason): - self.content, self.stop_reason = content, stop_reason - - -def _drive(reply="here you go", url=URL): - """Run get_reply through one get_my_page tool call and capture what the - model was handed back.""" - calls = [] - responses = [ - _Resp([_Block(type="tool_use", name="get_my_page", id="t1", input={})], "tool_use"), - _Resp([_Block(type="text", text=reply)], "end_turn"), - ] - - def _create(**kw): - calls.append(kw) - return responses[len(calls) - 1] - - with patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch("palmer.home.ensure_fresh", return_value=url) as ensure, \ - patch.object(agent.client.messages, "create", side_effect=_create): - text, _gif = agent.get_reply("+1555", "send me my page") - # the tool_result the model saw, on the second request - result = calls[1]["messages"][-1]["content"][0]["content"] - return text, result, ensure - - -class TestDispatch: - def test_it_returns_the_live_url(self): - _, result, _ = _drive() - assert URL in result - - def test_it_refreshes_the_page_for_this_caller(self): - """ensure_fresh, never a bare URL builder — a link to a 404 or to yesterday's data - is worse than no link.""" - _, _, ensure = _drive() - ensure.assert_called_once_with("+1555") - - def test_it_tells_the_model_where_to_put_the_url(self): - _, result, _ = _drive() - assert "end of your reply" in result.lower() - - def test_the_reply_reaches_the_user(self): - text, _, _ = _drive(reply=f"all yours {URL}") - assert text.endswith(URL) - - def test_no_app_url_does_not_promise_a_link(self): - _, result, _ = _drive(url="/h/tok") - assert URL not in result - assert "not mention a page" in result.lower() - - def test_no_app_url_still_answers_instead_of_erroring(self): - text, _, _ = _drive(reply="not much going on", url="/h/tok") - assert text == "not much going on" - - -class TestPriceTopicNormalization: - """"add Nvidia to my site" has to end up as something the Markets section - can resolve. The drafting model often writes the ticker itself and - sometimes doesn't, and the failure was silent — the topic appeared under - "Watching" with no price anywhere.""" - - def test_a_topic_the_map_already_covers_is_untouched(self): - """No model call: it already resolves.""" - with patch("palmer.llm.client") as client: - assert agent._normalize_price_topic("Nvidia stock") == "Nvidia stock" - client.messages.create.assert_not_called() - - def test_an_unmapped_company_gains_its_ticker(self): - with patch("palmer.tickers.resolve_company_ticker", return_value="LULU"): - assert agent._normalize_price_topic("Lululemon shares") == "Lululemon shares (LULU)" - - def test_an_unverifiable_company_gains_nothing(self): - """No ticker is appended when nothing tradeable can be confirmed.""" - with patch("palmer.tickers.resolve_company_ticker", return_value=None): - assert agent._normalize_price_topic("Stripe stock") == "Stripe stock" - - def test_a_news_topic_never_pays_for_a_lookup(self): - with patch("palmer.llm.client") as client: - for t in ("AI news", "St. Louis Cardinals", "Kirkwood weather"): - assert agent._normalize_price_topic(t) == t - client.messages.create.assert_not_called() - - def test_an_unresolvable_topic_is_left_alone(self): - with patch("palmer.tickers.resolve_company_ticker", return_value=None): - assert agent._normalize_price_topic("some obscure stock") == "some obscure stock" - - def test_empty_input_is_safe(self): - assert agent._normalize_price_topic("") == "" - - def test_it_runs_on_the_add_path(self): - """Normalization has to happen where topics are SAVED, since the read - path runs on every page view and must stay free.""" - import inspect - src = inspect.getsource(agent.get_reply) - block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] - assert "_normalize_price_topic" in block - - -class TestAddingToTheSiteRefreshesIt: - """The morning list and the page are one list, so a topic change has to be - visible on the page immediately — not after the 5-minute price cooldown.""" - - def test_the_add_path_expires_the_price_cache(self): - import inspect - src = inspect.getsource(agent.get_reply) - block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] - assert "invalidate" in block, "a topic change must expire the cached prices" - - def test_a_failure_to_invalidate_does_not_break_the_reply(self): - import inspect - src = inspect.getsource(agent.get_reply) - block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] - assert "except" in block, "the reply matters more than the cache stamp" - - def test_the_tool_description_covers_site_vocabulary(self): - from palmer.tools_def import TOOLS - d = next(t for t in TOOLS if t["name"] == "update_morning_briefing")["description"].lower() - for word in ("markets", "site", "page", "morning"): - assert word in d, f"users say {word!r} and mean this tool" - - def test_the_prompt_separates_asking_a_price_from_tracking_one(self): - from palmer import prompts - block = prompts.SYSTEM_PROMPT.lower() - assert "one-off" in block and "update_morning_briefing" in block - - -class TestPalmerCanSeeTheReminders: - """Watches and price watches were both listed in the system prompt. - Reminders — the one thing the user explicitly asked to happen at a named - time — were the table the model could not read at all. So "what have I got - on today" had nothing to answer from, and "cancel my 4pm one" was a guess - against twenty messages of history, against a tool that deletes.""" - - PHONE = "+15550009999" - - def _system(self, pending): - from unittest.mock import patch - from palmer import agent - with patch.object(agent, "get_profile", - return_value={"timezone": "America/Chicago"}), \ - patch("palmer.db.get_pending_reminders", return_value=pending), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - return agent._build_system(self.PHONE) - - def test_pending_reminders_reach_the_prompt(self): - out = self._system([{"id": 1, "text": "move the car", - "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) - assert "move the car" in out - - def test_they_are_shown_on_the_users_clock(self): - """21:00Z is 4pm in Chicago. Showing UTC is how a confirmation ends up - naming an hour the user never said.""" - out = self._system([{"id": 1, "text": "move the car", - "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) - assert "4:00 PM" in out - assert "21:00" not in out - - def test_a_repeat_says_so(self): - out = self._system([{"id": 1, "text": "take the bins out", - "due_at": "2026-09-04T21:00:00+00:00", "recurrence": "weekly"}]) - assert "repeats weekly" in out - - def test_the_bulk_cancel_hazard_is_stated(self): - out = self._system([{"id": 1, "text": "move the car", - "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) - assert "no text_match takes" in out - assert "ask which" in out - - def test_no_reminders_adds_nothing(self): - assert "Reminders they have set" not in self._system([]) - - def test_a_failed_read_does_not_break_the_turn(self): - from unittest.mock import patch - from palmer import agent - with patch.object(agent, "get_profile", return_value={}), \ - patch("palmer.db.get_pending_reminders", side_effect=RuntimeError("db down")), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - assert agent._build_system(self.PHONE) - - -class TestACancelSaysWhatItTook: - """cancel_reminders returned a bare count on a tool whose no-argument form - deletes every pending reminder, and whose text_match is a substring — so - "call" takes "call mom" and "call the vet" together.""" - - PHONE = "+15550008888" - - def _seed(self): - from palmer import db - db.cancel_reminders(self.PHONE) - for text in ("call mom", "call the vet", "move the car"): - db.save_reminder(self.PHONE, text, "2099-01-01T12:00:00+00:00") - - def test_it_returns_the_texts_that_went(self): - from palmer import db - self._seed() - gone = db.cancel_reminders_named(self.PHONE, "call") - assert sorted(gone) == ["call mom", "call the vet"] - - def test_the_unmatched_one_survives(self): - from palmer import db - self._seed() - db.cancel_reminders_named(self.PHONE, "call") - left = [r["text"] for r in db.get_pending_reminders(self.PHONE)] - assert left == ["move the car"] - - def test_the_count_form_still_works(self): - from palmer import db - self._seed() - assert db.cancel_reminders(self.PHONE) == 3 - - def test_the_dispatch_names_them(self): - import inspect - from palmer import agent - block = inspect.getsource(agent.get_reply).split('"cancel_reminders"')[1] \ - .split("elif b.name")[0] - assert "cancel_reminders_named" in block - assert "Say which ones went" in block diff --git a/tests/test_news.py b/tests/test_news.py new file mode 100644 index 0000000..f73ef11 --- /dev/null +++ b/tests/test_news.py @@ -0,0 +1,739 @@ +"""Source quality, trends and tickers. + +Merged from test_sources.py, test_trends.py, test_tickers.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import json +import pytest +from datetime import datetime, timezone, timedelta, date +from pathlib import Path +from unittest.mock import patch, MagicMock +from palmer import sources, datafeeds, trends, tickers +from tests.helpers import llm_reply +from palmer.tickers import resolve_topic_asset as resolve + + +# ============================================================================ +# from test_sources.py +# ============================================================================ +# +# Tests for sources.py — the one place that decides which news Palmer repeats. +# +# Every news surface (watch alerts, the morning briefing, Palmer Home, and the +# conversation search) reaches this module through datafeeds._search_raw, so a +# regression here is a regression everywhere at once. The integration class at +# the bottom pins that wiring; the rest pin the gate itself. +# +# trusted_sources.json is meant to be hand-edited without touching code, which +# makes TestSourceListIntegrity load-bearing rather than pedantic — a stray +# "https://" or a domain listed in both the allowlist and the blocklist would +# otherwise fail silently at runtime. + +def _fresh(hours_ago: float = 1) -> str: + return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat() + + +def _r(url: str, score: float = 0.7, hours_ago: float = 1) -> dict: + return {"url": url, "title": "t", "content": "c", "score": score, + "published_date": _fresh(hours_ago)} + + +class TestIsBlocked: + def test_press_release_wire_blocked(self): + assert sources.is_blocked("https://www.prnewswire.com/news-releases/thing-123.html") + assert sources.is_blocked("https://globenewswire.com/x") + + def test_republishing_aggregator_blocked(self): + assert sources.is_blocked("https://www.msn.com/en-us/news/other/story") + assert sources.is_blocked("https://biztoc.com/x/abc") + assert sources.is_blocked("https://news.google.com/articles/xyz") + + def test_subdomain_of_blocked_host_blocked(self): + assert sources.is_blocked("https://ir.globenewswire.com/x") + + def test_real_newsroom_not_blocked(self): + assert not sources.is_blocked("https://www.reuters.com/world/x") + + def test_lookalike_suffix_not_blocked(self): + """notmsn.com must not match msn.com — suffix matching is on a dot boundary.""" + assert not sources.is_blocked("https://notmsn.com/x") + + def test_malformed_and_empty_are_not_blocked(self): + assert not sources.is_blocked("") + assert not sources.is_blocked("not a url") + + +class TestSourceTier: + def test_tier1_newsroom(self): + assert sources.source_tier("https://www.reuters.com/world/x") == 1 + + def test_tier2_outlet(self): + assert sources.source_tier("https://www.theverge.com/x") == 2 + + def test_unknown_is_tier3(self): + assert sources.source_tier("https://contentfarm.example/x") == 3 + + def test_gov_and_edu_are_tier1(self): + assert sources.source_tier("https://www.weather.gov/alert/1") == 1 + assert sources.source_tier("https://news.mit.edu/2026/x") == 1 + + def test_subdomain_inherits_tier(self): + assert sources.source_tier("https://feeds.bbc.co.uk/news/x") == 1 + + def test_lookalike_suffix_does_not_inherit_tier(self): + """A domain ending in the trusted string but not on a dot boundary is + tier 3 — otherwise notreuters.com would launder itself into tier 1.""" + assert sources.source_tier("https://notreuters.com/x") == 3 + assert sources.source_tier("https://reuters.com.evil.example/x") == 3 + + def test_blocked_domain_still_reports_tier3(self): + """is_blocked is the gate; tier is only an ordering. They are separate + so a blocked host can never be ranked as merely-untrusted by accident.""" + assert sources.source_tier("https://www.msn.com/x") == 3 + + def test_malformed_url_is_tier3(self): + assert sources.source_tier("") == 3 + assert sources.source_tier("garbage") == 3 + + +class TestCanonicalDomain: + def test_collapses_subdomain_to_known_domain(self): + assert sources.canonical_domain("https://www.reuters.com/x") == "reuters.com" + assert sources.canonical_domain("https://feeds.bbc.co.uk/n") == "bbc.co.uk" + + def test_unknown_domain_falls_back_to_last_two_labels(self): + assert sources.canonical_domain("https://a.b.contentfarm.example/x") == "contentfarm.example" + + def test_blocked_domains_are_canonicalized_too(self): + """Blocked hosts are normally dropped before counting, but corroboration + must not treat two msn.com copies as two independent sources if one ever + reaches it.""" + assert sources.canonical_domain("https://www.msn.com/en-us/x") == "msn.com" + + def test_empty_on_garbage(self): + assert sources.canonical_domain("") == "" + assert sources.canonical_domain("not a url") == "" + + +class TestRank: + def test_drops_blocked_results(self): + out = sources.rank([_r("https://www.msn.com/a"), _r("https://www.reuters.com/b")]) + assert [r["url"] for r in out] == ["https://www.reuters.com/b"] + + def test_tier_beats_score(self): + """A wire report at score 0.5 outranks a content farm at 0.99 — Tavily's + score measures query match, not whether the page is worth believing.""" + out = sources.rank([ + _r("https://contentfarm.example/a", score=0.99), + _r("https://www.reuters.com/b", score=0.50), + ]) + assert [r["url"] for r in out] == ["https://www.reuters.com/b", + "https://contentfarm.example/a"] + + def test_score_breaks_ties_within_a_tier(self): + out = sources.rank([ + _r("https://www.reuters.com/low", score=0.4), + _r("https://apnews.com/high", score=0.9), + ]) + assert [r["url"] for r in out] == ["https://apnews.com/high", + "https://www.reuters.com/low"] + + def test_missing_score_does_not_raise(self): + out = sources.rank([{"url": "https://www.reuters.com/a"}]) + assert len(out) == 1 + + def test_trusted_only_drops_tier3(self): + out = sources.rank([ + _r("https://contentfarm.example/a", score=0.99), + _r("https://www.theverge.com/b", score=0.1), + ], trusted_only=True) + assert [r["url"] for r in out] == ["https://www.theverge.com/b"] + + def test_trusted_only_can_return_nothing(self): + """Palmer Home would rather show no row than an untrusted one.""" + assert sources.rank([_r("https://contentfarm.example/a")], trusted_only=True) == [] + + def test_default_keeps_tier3_as_last_resort(self): + """Conversation and the morning briefing answer a real question, so an + obscure-but-real source beats 'nothing found'.""" + out = sources.rank([_r("https://contentfarm.example/a")]) + assert len(out) == 1 + + def test_empty_input(self): + assert sources.rank([]) == [] + + +class TestMeetsScore: + """The relevance floor runs before the tier sort, so a flat floor lets an + SEO-tuned content farm survive a cut that a real newsroom does not.""" + + def test_tier3_gets_no_slack(self): + assert not sources.meets_score("https://contentfarm.example/a", 0.45, 0.5) + assert sources.meets_score("https://contentfarm.example/a", 0.50, 0.5) + + def test_trusted_source_clears_a_lower_bar(self): + assert sources.meets_score("https://www.reuters.com/a", 0.45, 0.5) + assert sources.meets_score("https://www.theverge.com/a", 0.40, 0.5) + + def test_trusted_slack_is_bounded(self): + """Relaxed, not waived — an off-topic wire story is still off-topic.""" + assert not sources.meets_score("https://www.reuters.com/a", 0.20, 0.5) + + def test_missing_score_never_passes_a_positive_floor(self): + assert not sources.meets_score("https://www.reuters.com/a", None, 0.5) + + def test_floor_never_goes_negative(self): + assert sources.meets_score("https://www.reuters.com/a", 0.0, 0.1) + + +class TestSourceListIntegrity: + """trusted_sources.json is edited by hand and read at import. These pin the + shape so a typo fails the suite instead of silently demoting a newsroom.""" + + @classmethod + def setup_class(cls): + with open(Path(sources.__file__).parent / "trusted_sources.json") as f: + cls.data = json.load(f) + + def test_every_allowlist_entry_has_domain_and_valid_tier(self): + for d in self.data["domains"]: + assert d.get("domain"), f"entry missing domain: {d}" + assert d.get("tier") in (1, 2), f"{d['domain']} has tier {d.get('tier')}" + + def test_every_blocked_entry_explains_why(self): + """The 'why' is the guardrail against the list drifting from structural + junk into editorial opinion about who reports well.""" + for d in self.data["blocked"]: + assert d.get("domain"), f"blocked entry missing domain: {d}" + assert d.get("why"), f"{d['domain']} blocked with no reason given" + + def test_domains_are_bare_hosts(self): + for d in self.data["domains"] + self.data["blocked"]: + host = d["domain"] + assert host == host.lower(), f"{host} is not lowercase" + assert "://" not in host, f"{host} includes a scheme" + assert "/" not in host, f"{host} includes a path" + assert not host.startswith("www."), f"{host} should not carry a www. prefix" + assert "." in host, f"{host} is not a domain" + + def test_no_duplicate_domains(self): + hosts = [d["domain"] for d in self.data["domains"]] + assert len(hosts) == len(set(hosts)), \ + f"duplicates: {sorted({h for h in hosts if hosts.count(h) > 1})}" + + def test_allowlist_and_blocklist_do_not_overlap(self): + allowed = {d["domain"] for d in self.data["domains"]} + blocked = {d["domain"] for d in self.data["blocked"]} + assert not (allowed & blocked), f"listed as both trusted and blocked: {allowed & blocked}" + + def test_loaded_sets_match_the_file(self): + assert len(sources._TIER1_DOMAINS) + len(sources._TIER2_DOMAINS) == len(self.data["domains"]) + assert len(sources._BLOCKED_DOMAINS) == len(self.data["blocked"]) + + +class TestCorroboratedIsShared: + """test_corroboration.py imports corroborated from watches. It moved + here; this pins that watches still exposes the same object so the gate can + never fork into two implementations.""" + + def test_watches_reexports_the_same_function(self): + from palmer import watches + assert watches.corroborated is sources.corroborated + + +class TestSearchRawAppliesTheGate: + """The wiring that matters: every news surface goes through _search_raw, so + the gate has to be applied there rather than by each caller.""" + + def _tavily_returning(self, results): + fake = MagicMock() + fake.search.return_value = {"results": results} + return fake + + def test_blocked_results_never_reach_callers(self): + fake = self._tavily_returning([ + _r("https://www.prnewswire.com/a", score=0.99), + _r("https://www.reuters.com/b", score=0.6), + ]) + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search_raw("some topic") + assert [r["url"] for r in out] == ["https://www.reuters.com/b"] + + def test_results_come_back_source_ranked(self): + fake = self._tavily_returning([ + _r("https://contentfarm.example/a", score=0.99), + _r("https://apnews.com/b", score=0.6), + ]) + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search_raw("some topic") + assert [r["url"] for r in out] == ["https://apnews.com/b", + "https://contentfarm.example/a"] + + def test_trusted_only_is_forwarded(self): + fake = self._tavily_returning([_r("https://contentfarm.example/a")]) + with patch.object(datafeeds, "_tavily", fake): + assert datafeeds._search_raw("t", trusted_only=True) == [] + assert len(datafeeds._search_raw("t")) == 1 + + def test_recency_and_score_filters_still_apply_first(self): + fake = self._tavily_returning([ + _r("https://apnews.com/stale", hours_ago=48), + _r("https://apnews.com/weak", score=0.1), # under the floor even with slack + _r("https://apnews.com/good"), + ]) + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search_raw("t", max_age_hours=12, min_score=0.5) + assert [r["url"] for r in out] == ["https://apnews.com/good"] + + def test_trusted_source_survives_a_cut_that_drops_a_content_farm(self): + """The end-to-end shape of the bug: the farm scores higher because it is + built to, and a flat floor would have left it as the only survivor.""" + fake = self._tavily_returning([ + _r("https://contentfarm.example/a", score=0.55), + _r("https://www.reuters.com/b", score=0.42), + ]) + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search_raw("t", min_score=0.5) + assert [r["url"] for r in out] == ["https://www.reuters.com/b", + "https://contentfarm.example/a"] + + def test_pulls_ten_candidates(self): + """The recency window throws most of a page away. Five candidates left + the tier sort nothing to choose between, which is how a lone content + farm became the best available source. Tavily bills per search, not per + result, so the wider pull is free.""" + fake = self._tavily_returning([]) + with patch.object(datafeeds, "_tavily", fake): + datafeeds._search_raw("t") + assert fake.search.call_args.kwargs["max_results"] == 10 + + def test_search_failure_returns_empty(self): + fake = MagicMock() + fake.search.side_effect = RuntimeError("tavily down") + with patch.object(datafeeds, "_tavily", fake): + assert datafeeds._search_raw("t") == [] + + +class TestConversationSearchLabelsSources: + """_search feeds the drafting model directly. It had no provenance at all, + so Palmer could not tell a wire report from a content farm.""" + + def test_domain_is_labelled_and_junk_is_dropped(self): + fake = MagicMock() + fake.search.return_value = {"results": [ + _r("https://www.msn.com/junk"), + _r("https://www.reuters.com/real"), + ]} + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search("what happened") + assert "[reuters.com]" in out + assert "msn.com" not in out + + def test_no_results_message_when_everything_is_filtered(self): + """An empty search says so — and says it is empty, not broken. + + This used to assert the literal "No results found.", which is the shape + of failure string that gets paraphrased into "I can't find news on that" + and then into a competitor. The gates firing is the common case, not a + rare one, so what the model is told here matters.""" + fake = MagicMock() + fake.search.return_value = {"results": [_r("https://biztoc.com/a")]} + with patch.object(datafeeds, "_tavily", fake): + out = datafeeds._search("q") + assert "'q'" in out # names what was searched for + assert "DO have news search" in out # never implies the capability is gone + assert "another site" in out # and never hands them off + + +# ============================================================================ +# from test_trends.py +# ============================================================================ +# +# Tests for the adjacent-story pick. +# +# This is the one part of the briefing that isn't something the user asked for, +# so the guards matter more than the feature: it must never invent a trend, never +# repeat what the briefing already covers, and must fail to silence rather than to +# filler. Haiku is mocked — its judgement quality is checked live, not here. + +CANDIDATES = [ + {"query": "jeff bezos zero income tax idea", "volume": 200000, "categories": ["Business"]}, + {"query": "espanyol vs real madrid", "volume": 500000, "categories": ["Sports"]}, +] +INTERESTS = ["Bitcoin and stocks", "SpaceX"] + + +def _patch_candidates(): + return patch.object(trends, "trending_now", return_value=list(CANDIDATES)) + + +class TestTrendingFetch: + def test_filters_low_volume_and_sorts_by_volume(self): + payload = {"trending_searches": [ + {"query": "tiny", "search_volume": 10, "categories": []}, + {"query": "big", "search_volume": 500000, "categories": [{"name": "News"}]}, + {"query": "mid", "search_volume": 50000, "categories": []}, + ]} + trends._cache.clear() + with patch.object(trends.serpapi, "search", return_value=payload): + out = trends.trending_now("US", date(2026, 8, 23)) + assert [i["query"] for i in out] == ["big", "mid"], "low-volume noise must be dropped" + + def test_cached_per_geo_and_day(self): + """Trending is identical for everyone — one fetch should serve the run.""" + trends._cache.clear() + payload = {"trending_searches": [{"query": "x", "search_volume": 100000, "categories": []}]} + with patch.object(trends.serpapi, "search", return_value=payload) as api: + trends.trending_now("US", date(2026, 8, 23)) + trends.trending_now("US", date(2026, 8, 23)) + assert api.call_count == 1 + + def test_new_day_refetches(self): + trends._cache.clear() + payload = {"trending_searches": [{"query": "x", "search_volume": 100000, "categories": []}]} + with patch.object(trends.serpapi, "search", return_value=payload) as api: + trends.trending_now("US", date(2026, 8, 23)) + trends.trending_now("US", date(2026, 8, 24)) + assert api.call_count == 2 + + def test_api_failure_returns_empty(self): + trends._cache.clear() + with patch.object(trends.serpapi, "search", side_effect=RuntimeError("boom")): + assert trends.trending_now("US", date(2026, 8, 23)) == [] + + +class TestAdjacentPick: + def test_returns_story_for_a_valid_pick(self): + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", + return_value=llm_reply('{"query": "jeff bezos zero income tax idea", "why": "markets angle"}')), \ + patch.object(trends, "_search_raw", + return_value=[{"title": "Bezos floats plan", "content": "details"}]): + out = trends.adjacent_story(INTERESTS, ["Bitcoin flat"]) + assert out["query"] == "jeff bezos zero income tax idea" + assert "Bezos floats plan" in out["story"] + + def test_none_verdict_is_respected(self): + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", return_value=llm_reply('{"query": "NONE"}')), \ + patch.object(trends, "_search_raw") as search: + assert trends.adjacent_story(INTERESTS, []) is None + search.assert_not_called() + + def test_invented_trend_is_rejected(self): + """A hallucinated trend is exactly the filler this feature must not add.""" + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", + return_value=llm_reply('{"query": "aliens land in ohio", "why": "big if true"}')), \ + patch.object(trends, "_search_raw") as search: + assert trends.adjacent_story(INTERESTS, []) is None + search.assert_not_called() + + def test_pick_with_no_story_behind_it_is_dropped(self): + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", + return_value=llm_reply('{"query": "jeff bezos zero income tax idea", "why": "x"}')), \ + patch.object(trends, "_search_raw", return_value=[]): + assert trends.adjacent_story(INTERESTS, []) is None + + def test_covered_headlines_reach_the_prompt(self): + captured = {} + + def _create(**kw): + captured["p"] = kw["messages"][0]["content"] + return llm_reply('{"query": "NONE"}') + + with _patch_candidates(), patch.object(trends.client.messages, "create", side_effect=_create): + trends.adjacent_story(INTERESTS, ["Bitcoin flat at 77k"]) + assert "Bitcoin flat at 77k" in captured["p"], "the pick must know what's already covered" + + def test_no_interests_means_no_pick(self): + with patch.object(trends, "trending_now") as t: + assert trends.adjacent_story([], []) is None + t.assert_not_called() + + def test_model_failure_is_silent(self): + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", side_effect=RuntimeError("boom")): + assert trends.adjacent_story(INTERESTS, []) is None + + def test_unparseable_reply_is_silent(self): + with _patch_candidates(), \ + patch.object(trends.client.messages, "create", return_value=llm_reply("not json")): + assert trends.adjacent_story(INTERESTS, []) is None + + +class TestBriefingIntegration: + def test_adjacent_failure_does_not_break_the_briefing(self): + from palmer import morning + with patch.object(morning, "_weather_report", return_value="warm"), \ + patch.object(morning, "_topic_digest", return_value="story"), \ + patch.object(morning, "get_city_traffic", return_value="clear"), \ + patch.object(morning, "get_travel_time", return_value="17 min"), \ + patch("palmer.trends.adjacent_story", side_effect=RuntimeError("boom")): + out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) + assert any("story" in s for s in out), "topics must survive a trends failure" + + def test_adjacent_section_is_labelled_for_the_drafter(self): + from palmer import morning + with patch.object(morning, "_weather_report", return_value="warm"), \ + patch.object(morning, "_topic_digest", return_value="story"), \ + patch.object(morning, "get_city_traffic", return_value="clear"), \ + patch.object(morning, "get_travel_time", return_value="17 min"), \ + patch("palmer.trends.adjacent_story", + return_value={"query": "q", "why": "close to markets", "story": "Big thing happened"}): + out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) + adj = [s for s in out if s.startswith("ADJACENT")] + assert adj and "close to markets" in adj[0] and "Big thing happened" in adj[0] + + +# ============================================================================ +# from test_tickers.py +# ============================================================================ +# +# Topic -> symbol resolution. +# +# The Markets section is derived from morning topics, so this resolver decides +# whether "add Nvidia to my site" produces a price or silently produces nothing. +# It used to be a bare uppercase-word regex, which meant it worked only when the +# model happened to write the ticker into the topic itself. +# +# Two failure directions matter and both are tested: a real company that resolves +# to NOTHING (silently empty Markets section) and a non-company that resolves to +# SOMETHING (a wrong price on someone's personal page). The second is worse. + +class TestCompanyNames: + """The actual bug: users say "Nvidia", not "NVDA".""" + + @pytest.mark.parametrize("topic,symbol", [ + ("Nvidia stock", "NVDA"), + ("Tesla stock", "TSLA"), + ("Apple shares", "AAPL"), + ("coinbase stock", "COIN"), + ("microsoft stock price", "MSFT"), + ("what's amazon stock doing", "AMZN"), + ]) + def test_a_company_name_resolves(self, topic, symbol): + assert resolve(topic)[0] == symbol + + def test_case_does_not_matter(self): + assert resolve("NVIDIA STOCK")[0] == "NVDA" + assert resolve("nvidia stock")[0] == "NVDA" + + def test_the_longest_name_wins(self): + """"dow jones" must not be shadowed by a shorter key.""" + assert resolve("dow jones")[0] == "^DJI" + assert resolve("s&p 500")[0] == "^GSPC" + + +class TestExplicitSymbols: + def test_a_parenthesised_ticker_is_used(self): + """This is the shape the drafting model writes on its own.""" + assert resolve("Nvidia stock price (NVDA)")[0] == "NVDA" + + def test_a_dollar_prefixed_ticker_is_used(self): + assert resolve("$TSLA")[0] == "TSLA" + + def test_a_bare_ticker_with_a_price_word_is_used(self): + assert resolve("TSLA stock")[0] == "TSLA" + + def test_an_explicit_symbol_beats_the_name_map(self): + """If the user spelled out a symbol, trust it over a name match.""" + assert resolve("Alphabet stock (GOOG)")[0] == "GOOG" + + +class TestFalsePositives: + """A wrong ticker is worse than no ticker — it puts a real price for the + wrong thing on someone's page, and nothing downstream can catch it.""" + + def test_us_stock_market_is_not_the_ticker_US(self): + """The live bug: "US stock market" resolved to "US", which yfinance + rejects as delisted on every single page refresh.""" + got = resolve("US stock market") + assert got is not None and got[0] != "US" + + def test_the_generic_market_ask_resolves_to_the_sp500(self): + assert resolve("US stock market")[0] == "^GSPC" + assert resolve("stock market updates")[0] == "^GSPC" + + @pytest.mark.parametrize("topic", [ + "AI news", "US politics", "Fintech news", "movie news", + "St. Louis Cardinals", "Philadelphia Eagles news", + "National and international news", "Kirkwood, MO weather", + "Trump social media posts (overnight)", "Daily fun fact from history", + "SpaceX news", + ]) + def test_a_news_topic_gets_no_ticker(self, topic): + assert resolve(topic) is None + + def test_a_price_word_alone_is_not_enough(self): + """"stock" in the sentence must not make any capitalised word a ticker.""" + assert resolve("stock up on groceries") is None + + @pytest.mark.parametrize("word", ["US", "AI", "ETF", "IPO", "CEO", "NFL", "THE"]) + def test_known_non_tickers_are_rejected(self, word): + assert word in tickers.NOT_TICKERS + + +class TestNewsTopicsAreNotPriceTopics: + """A company name is an ordinary word in a news topic. Without a price-word + gate, "SpaceX news" resolves to SPCX and a subject someone follows silently + grows a stock ticker in their Markets section.""" + + @pytest.mark.parametrize("topic", [ + "SpaceX news", "Disney movie news", "Nike news", + "Apple event coverage", "Tesla recall coverage", "Amazon layoffs", + ]) + def test_a_company_in_a_news_topic_gets_no_ticker(self, topic): + assert resolve(topic) is None + + @pytest.mark.parametrize("topic,symbol", [ + ("SpaceX stock", "SPCX"), ("Disney stock", "DIS"), ("Nike stock", "NKE"), + ]) + def test_the_same_company_with_a_price_word_does_resolve(self, topic, symbol): + assert resolve(topic)[0] == symbol + + def test_indices_need_no_price_word(self): + """"nasdaq" is unambiguously a market reference on its own.""" + assert resolve("nasdaq")[0] == "^IXIC" + assert resolve("the dow")[0] == "^DJI" + + def test_crypto_needs_no_price_word(self): + """Pre-existing behaviour: a live user tracks "Bitcoin and major stock + news" and has always gotten a price for it.""" + assert resolve("Bitcoin and major stock news")[0] == "bitcoin" + + +class TestSearchResolution: + """The save-path resolver. No model is asked for a symbol — two earlier + versions did, and both encoded a stale snapshot of who was public.""" + + def _search(self, quotes, topic="Lululemon shares"): + with patch("palmer.netutil._http_get_json", return_value={"quotes": quotes}) as get: + return tickers.search_symbol(topic), get + + def test_a_us_equity_is_returned(self): + assert self._search([{"symbol": "LULU", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "LULU" + + def test_a_tokenized_crypto_is_rejected(self): + """Unfiltered, "openai" comes back as a crypto token that merely shares + the name — a real price for something that is not the company.""" + assert self._search([{"symbol": "OPENAI-USD", "quoteType": "CRYPTOCURRENCY", + "exchange": "CCC"}])[0] is None + + def test_a_thematic_etf_is_rejected(self): + assert self._search([{"symbol": "OAIW", "quoteType": "ETF", "exchange": "PCX"}])[0] is None + + def test_a_mutual_fund_is_rejected(self): + assert self._search([{"symbol": "STRIZZX", "quoteType": "MUTUALFUND", + "exchange": "NAS"}])[0] is None + + def test_a_foreign_listing_is_skipped_for_the_us_one(self): + """"lululemon" also matches Milan and Sao Paulo lines of the same + company, which price in the wrong currency.""" + assert self._search([{"symbol": "1LUL.MI", "quoteType": "EQUITY", "exchange": "MIL"}, + {"symbol": "LULU", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "LULU" + + def test_the_first_qualifying_result_wins(self): + assert self._search([{"symbol": "SPACEX-USD", "quoteType": "CRYPTOCURRENCY", "exchange": "CCC"}, + {"symbol": "SPCX", "quoteType": "EQUITY", "exchange": "NMS"}, + {"symbol": "SPCF", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "SPCX" + + def test_no_qualifying_result_yields_nothing(self): + assert self._search([])[0] is None + + def test_a_network_failure_yields_nothing(self): + with patch("palmer.netutil._http_get_json", return_value=None): + assert tickers.search_symbol("Lululemon shares") is None + + def test_a_stopword_symbol_is_still_rejected(self): + assert self._search([{"symbol": "AI", "quoteType": "EQUITY", "exchange": "NYQ"}])[0] is None + + def test_no_model_is_consulted(self): + """The whole point of the rewrite.""" + with patch("palmer.llm.client") as client, \ + patch("palmer.netutil._http_get_json", return_value={"quotes": []}): + tickers.resolve_company_ticker("Lululemon shares") + client.messages.create.assert_not_called() + + +class TestSearchQuery: + """Search matches names, not sentences.""" + + def test_price_words_are_stripped(self): + """"spacex stock" returns nothing from search; "spacex" returns SPCX.""" + assert tickers._search_query("spacex stock") == "spacex" + assert tickers._search_query("Lululemon shares") == "Lululemon" + assert tickers._search_query("Duolingo stock price") == "Duolingo" + + def test_topic_filler_is_stripped(self): + assert tickers._search_query("the latest Nvidia news updates") == "Nvidia" + + def test_a_multiword_company_survives(self): + assert tickers._search_query("Rocket Lab stock") == "Rocket Lab" + + def test_an_empty_query_short_circuits(self): + with patch("palmer.netutil._http_get_json") as get: + assert tickers.search_symbol("stock price news") is None + get.assert_not_called() + + def test_indices_never_reach_search(self): + """Search returns futures for them - "s&p 500" is ES=F, not ^GSPC - so + they must resolve from INDEX_TICKERS before search is consulted.""" + for name in ("s&p 500", "nasdaq", "the dow", "US stock market"): + got = resolve(name) + assert got and got[0].startswith("^"), f"{name} must map to an index" + + +class TestCrypto: + def test_crypto_still_resolves(self): + assert resolve("Bitcoin price")[0] == "bitcoin" + assert resolve("bitcoin")[0] == "bitcoin" + + def test_crypto_wins_over_a_stray_ticker_match(self): + assert resolve("Bitcoin and major stock news")[0] == "bitcoin" + + +class TestLabels: + """Yahoo's index symbols are correct and unreadable.""" + + def test_an_index_gets_a_human_label(self): + assert resolve("s&p 500") == ("^GSPC", "S&P 500") + assert resolve("nasdaq") == ("^IXIC", "Nasdaq") + + def test_a_plain_ticker_labels_as_itself(self): + assert resolve("Nvidia stock") == ("NVDA", "NVDA") + + def test_crypto_labels_readably(self): + assert resolve("bitcoin")[1] == "Bitcoin" + + def test_crypto_alias_labels_readably_too(self): + """"Btc"/"Avax" (a naive title-case of the matched alias) used to reach + the page. The coingecko id behind the alias decides the real name.""" + assert resolve("add BTC to my markets")[1] == "Bitcoin" + assert resolve("avax price")[1] == "Avalanche" + assert resolve("what's XRP at")[1] == "XRP" + + +class TestPriceTopicGate: + """Gates the paid fallback so ordinary news topics never trigger one.""" + + @pytest.mark.parametrize("topic", ["Nvidia stock", "bitcoin price", + "AAPL shares", "the market"]) + def test_price_topics_pass(self, topic): + assert tickers.looks_like_price_topic(topic) + + @pytest.mark.parametrize("topic", ["AI news", "St. Louis Cardinals", + "Kirkwood weather", "movie news"]) + def test_news_topics_do_not(self, topic): + assert not tickers.looks_like_price_topic(topic) + + +class TestReadPathIsFree: + def test_resolution_never_calls_a_model(self): + """This runs on every page view. A model call here would be a bill.""" + with patch("palmer.llm.client") as client: + for t in ["Nvidia stock", "AI news", "US stock market", "$TSLA", + "SpaceX stock", "bitcoin", "Lululemon shares"]: + resolve(t) + client.messages.create.assert_not_called() diff --git a/tests/test_onboard.py b/tests/test_onboard.py deleted file mode 100644 index fc56434..0000000 --- a/tests/test_onboard.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Setup form: the link on message one, the one-shot write, and what it seeds. - -The regressions these hold are the ones the flow exists to fix — a city that -lands too late to seed local news — plus the two rules that shape it: the link -goes out at the setup moment through get_my_page, never on message one, and -the box is free text so topics arrive subject-shaped. -""" -from unittest.mock import patch - -import pytest - -from palmer import db -from palmer import onboard - - -@pytest.fixture -def store(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "onboard.db", raising=False) - monkeypatch.setenv("APP_URL", "https://palmer.test") - db.init_db() - return db - - -class _Form(dict): - """Starlette's FormData interface, as much of it as onboard.apply uses.""" - - -class TestStart: - def test_mints_a_token_and_parks_a_stub(self, store): - with patch("palmer.home.save") as save, patch("palmer.home.load", return_value=None): - url = onboard.start("+15550001111") - assert url.startswith("https://palmer.test/h/") - token, payload = save.call_args[0] - assert payload == {"phone": "+15550001111", "setup_pending": True} - assert token in url - - def test_no_app_url_means_no_link(self, store, monkeypatch): - monkeypatch.delenv("APP_URL", raising=False) - assert onboard.start("+15550001111") is None - - def test_does_not_clobber_an_existing_page(self, store): - """A user who already has a payload is not a new user; never overwrite - a live page with a setup stub.""" - with patch("palmer.home.save") as save, patch("palmer.home.load", return_value={"built_at": 1}): - onboard.start("+15550001111") - save.assert_not_called() - - def test_never_raises(self, store): - with patch("palmer.home.home_token", side_effect=RuntimeError("db down")): - assert onboard.start("+15550001111") is None - - -class TestApply: - def _apply(self, phone, form, city_topics=("Austin local news", - "National and international news")): - payload = {"phone": phone, "setup_pending": True} - with patch("palmer.morning.default_topics", return_value=list(city_topics)), \ - patch("palmer.home.save") as save, \ - patch.object(onboard, "_build_async") as build, \ - patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t), \ - patch("palmer.userprofile._eager_build_home"): - ok = onboard.apply("tok", payload, form) - return ok, save, build - - def test_writes_name_city_and_topics(self, store): - form = _Form({"name": "Jeff", "city": "Austin, TX", "mornings": "on", - "follows": "Eagles, AI"}) - ok, _, build = self._apply("+15550002222", form) - assert ok is True - p = db.get_profile("+15550002222") - assert p["name"] == "Jeff" - assert p["city"] == "Austin, TX" - assert p["morning_enabled"] is True - assert p["morning_onboarded"] is True - assert p["setup_done"] is True - assert "Eagles" in p["morning_topics"] - assert "AI" in p["morning_topics"] - build.assert_called_once() - - def test_local_news_is_seeded_because_the_city_is_known_first(self, store): - """The whole point of the form. update_morning_briefing's dispatch seeds - from `profile["city"]`, which is still empty during the reply that turns - mornings on — so the chat path silently seeds national news alone.""" - form = _Form({"name": "Jeff", "city": "Austin, TX"}) - self._apply("+15550002223", form) - topics = db.get_profile("+15550002223")["morning_topics"] - assert "Austin local news" in topics - - def test_timezone_is_derived_from_the_typed_city(self, store): - """Without it every local_today() call degrades to UTC and the morning - job has no local clock to aim at.""" - form = _Form({"name": "Jeff", "city": "Austin, TX"}) - with patch("palmer.userprofile._derive_timezone", return_value="America/Chicago"): - self._apply("+15550002224", form) - assert db.get_profile("+15550002224")["timezone"] == "America/Chicago" - - def test_second_submit_is_refused(self, store): - """The token is the page's only protection and a form turns a read key - into a write key, so the write is one-shot.""" - form = _Form({"name": "Jeff", "city": "Austin, TX"}) - self._apply("+15550002225", form) - again = _Form({"name": "Mallory", "city": "Nowhere"}) - ok, _, _ = self._apply_already_done("+15550002225", again) - assert ok is False - assert db.get_profile("+15550002225")["name"] == "Jeff" - - def _apply_already_done(self, phone, form): - payload = {"phone": phone, "setup_pending": False} - with patch.object(onboard, "_build_async") as build: - ok = onboard.apply("tok", payload, form) - return ok, None, build - - def test_mornings_unchecked_stays_off(self, store): - form = _Form({"name": "Jeff", "city": "Austin, TX"}) - self._apply("+15550002226", form) - assert db.get_profile("+15550002226")["morning_enabled"] is False - - def test_closes_the_window_before_writing(self, store): - form = _Form({"name": "Jeff", "city": "Austin, TX"}) - _, save, _ = self._apply("+15550002227", form) - assert save.call_args_list[0][0][1] == {"phone": "+15550002227", - "setup_pending": False} - - -class TestTopics: - def _topics(self, city, follows, base=("National and international news",)): - with patch("palmer.morning.default_topics", return_value=list(base)), \ - patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t): - return onboard._topics_for(city, follows) - - def test_free_text_splits_on_commas_not_and(self): - """"Simon and Garfunkel" is one thing; a comma is the separator.""" - assert self._topics("Austin", "Eagles, Simon and Garfunkel; AI\nNvidia stock") == [ - "National and international news", "Eagles", "Simon and Garfunkel", "AI", - "Nvidia stock"] - - def test_blank_and_duplicates_dropped(self): - assert self._topics("Austin", " , eagles, Eagles, . ") == [ - "National and international news", "eagles"] - - def test_capped(self): - many = ", ".join(f"t{i}" for i in range(20)) - assert len(self._topics("Austin", many)) == onboard.TOPIC_MAX - - def test_each_entry_gets_the_texted_topic_normalization(self): - """"nvidia stock" typed on the form resolves a ticker exactly as it - would texted — same pass, so Markets renders either way.""" - with patch("palmer.morning.default_topics", return_value=[]), \ - patch("palmer.agent._normalize_price_topic", - side_effect=lambda t: f"{t} (NVDA)" if "nvidia" in t.lower() else t): - assert onboard._topics_for("Austin", "nvidia stock, AI") == [ - "nvidia stock (NVDA)", "AI"] - - def test_normalizer_failure_keeps_the_raw_topic(self): - with patch("palmer.morning.default_topics", return_value=[]), \ - patch("palmer.agent._normalize_price_topic", side_effect=RuntimeError("yahoo down")): - assert onboard._topics_for("Austin", "Eagles") == ["Eagles"] - - -class TestForm: - def test_posts_to_itself_with_a_free_text_follows_box(self): - out = onboard.render_setup("tok", action="https://palmer.test/h/tok") - assert 'method=post action="https://palmer.test/h/tok"' in out - assert 'name=follows' in out - assert 'type=checkbox name=interests' not in out - - def test_no_javascript_and_no_external_requests(self): - """Same constraint as page.py — it opens on a phone over a cell - connection and nowhere else.""" - out = onboard.render_setup("tok", action="/h/tok") - assert "Scores<" not in html +# ============================================================================ +# from test_cards.py +# ============================================================================ +# +# Tests for the dashboard renderer and the structured snapshots behind it. +# +# The card is enrichment layered on top of a briefing that must still arrive if +# rendering fails, so most of these assert on graceful degradation rather than on +# pixels. Visual quality is judged by looking at the output, not here. + +WEATHER = {"resolved": "Kirkwood, Missouri", "temp_now": 81.0, "feels_like": 84.0, + "humidity": 55, "wind": 7.0, "weather_code": 3, "description": "Overcast", + "high": 83.0, "low": 64.0, "rain_pct": 20, "gusts": None} +TRAFFIC = {"live_min": 17, "free_min": 16, "delay_min": 0, "miles": 13.7, "ratio": 1.024} +PRICES = [{"label": "Bitcoin", "price": 77752.0, "pct_24h": 1.1, "pct_7d": 0.4, + "series": [76000, 76500, 77000, 77752], "is_crypto": True}] +HEADS = ["Cards blown out 12-3", "SpaceX hits 100 launches"] +OPENING = [{"kind": "event", "title": "Todd Rundgren", "when": "Friday", "source": "t.com"}, + {"kind": "event", "title": "The Wallflowers", "when": "Saturday", "source": "t.com"}, + {"kind": "screen", "title": "Colony", "when": "in theaters", "source": "tmdb.org"}] + + +def _render_card(**kw): + args = dict(city="Kirkwood, MO", weather=WEATHER, traffic=TRAFFIC, + prices=PRICES, headlines=HEADS) + args.update(kw) + return cards.render_dashboard(**args) + + +class TestRender: + def test_produces_a_png_at_og_spec(self): + """1200x630 so one asset serves both the MMS card and the og:image.""" + img = Image.open(io.BytesIO(_render_card())) + assert img.format == "PNG" + assert img.size == (cards.W, cards.H) == (1200, 630) + + def test_not_blank(self): + img = Image.open(io.BytesIO(_render_card())).convert("RGB") + assert len(img.getcolors(maxcolors=1_000_000) or []) > 50, "render looks empty" + + def test_under_mms_size_limit(self): + assert len(_render_card()) < 5_000_000 + + def test_deterministic(self): + from datetime import datetime + when = datetime(2026, 8, 23, 7, 15) + assert _render_card(when=when) == _render_card(when=when) + + +class TestDegradesSection_by_section: + """A missing snapshot drops its panel; it must never fail the render, because + a briefing without a card is fine and a briefing that doesn't send is not.""" + + def test_each_section_may_be_absent(self): + for missing in ("weather", "traffic", "prices", "headlines"): + assert _render_card(**{missing: None}) + + def test_everything_absent(self): + assert _render_card(weather=None, traffic=None, prices=None, headlines=None) + + def test_partial_weather(self): + assert _render_card(weather={"description": "Overcast"}) + + def test_price_without_series(self): + assert _render_card(prices=[{"label": "X", "price": 1.5, "pct_24h": -2.0, "series": []}]) + + def test_long_headline_is_clipped_not_crashed(self): + assert _render_card(headlines=["x" * 400]) + + def test_missing_city(self): + assert _render_card(city="") + + +class TestOpeningBand: + """Opening draws in the left column between the weather chips and the news + rule — the one band of the card that was empty.""" + + def test_the_card_renders_with_opening(self): + img = Image.open(io.BytesIO(_render_card(opening=OPENING))) + assert img.size == (cards.W, cards.H) + + def test_it_changes_the_pixels(self): + """A section that draws nothing is a section that isn't there.""" + assert _render_card(opening=OPENING) != _render_card(opening=None) + + def test_absent_opening_is_fine(self): + assert _render_card(opening=None) and _render_card(opening=[]) + + def test_more_rows_than_fit_do_not_overflow_into_the_news_band(self): + many = [{"kind": "event", "title": f"Act number {i}", "when": "Friday"} + for i in range(9)] + a = _render_card(opening=many) + b = _render_card(opening=many[:cards.CARD_OPENING_ROWS]) + assert a == b, "rows past the cap must not be drawn at all" + + def test_a_very_long_title_is_clipped_rather_than_running_under_markets(self): + long = [{"kind": "local", "title": "A restaurant with an absurdly long name " * 4, + "when": "Friday"}] + assert _render_card(opening=long) + + +class TestCardCacheKey: + """The card image is memoised. It used to key on `built_at`, which only + advances inside home.rebuild() — and ensure_fresh calls rebuild only when + there is no payload at all. So after a user's first build the key never + changed again and the card froze on that morning's weather while the page + beside it stayed live.""" + + PAYLOAD = {"city": "Kirkwood, MO", "weather": WEATHER, "traffic": TRAFFIC, + "prices": PRICES, "opening": OPENING, + "headlines": [{"title": h} for h in HEADS], "built_at": 1} + + def _fp(self, **over): + from palmer import artifacts + return artifacts._card_fingerprint(dict(self.PAYLOAD, **over)) + + def test_identical_content_is_a_cache_hit(self): + assert self._fp() == self._fp() + + def test_a_stale_built_at_no_longer_freezes_the_card(self): + """Same built_at, different weather — the old key could not tell these + apart, which is the whole bug.""" + warmer = dict(WEATHER, temp_now=42.0) + assert self._fp(weather=warmer) != self._fp() + + def test_every_drawn_section_moves_the_key(self): + for field, value in (("city", "Denver, CO"), + ("traffic", dict(TRAFFIC, live_min=99)), + ("prices", []), + ("opening", []), + ("headlines", [{"title": "something else"}])): + assert self._fp(**{field: value}) != self._fp(), f"{field} must re-key" + + def test_something_not_drawn_does_not_move_the_key(self): + """tracking and the token never reach the renderer, so they must not + cost a re-render.""" + assert self._fp(tracking={"topics": ["new"]}) == self._fp() + + def test_render_png_reuses_the_image_for_identical_content(self): + from palmer import artifacts + artifacts._png_cache.clear() + a = artifacts.render_png("tok", dict(self.PAYLOAD)) + b = artifacts.render_png("tok", dict(self.PAYLOAD)) + assert a is b + + def test_render_png_redraws_when_the_content_moves(self): + from palmer import artifacts + artifacts._png_cache.clear() + a = artifacts.render_png("tok", dict(self.PAYLOAD)) + b = artifacts.render_png("tok", dict(self.PAYLOAD, weather=dict(WEATHER, temp_now=12.0))) + assert a != b + + def test_opening_reaches_the_renderer(self): + from palmer import artifacts + assert "opening" in artifacts._card_inputs(self.PAYLOAD) + + +class TestMeter: + def _ratio_colour(self, ratio): + img = Image.new("RGBA", (200, 60), (0, 0, 0, 255)) + return cards._meter(img, 10, 20, 160, ratio) + + def test_free_flowing_is_green(self): + assert self._ratio_colour(1.02) == cards.UP + + def test_moderate_is_amber(self): + assert self._ratio_colour(1.25) == cards.WARM + + def test_heavy_is_red(self): + assert self._ratio_colour(1.5) == cards.DOWN + + def test_extreme_ratio_clamps(self): + assert self._ratio_colour(9.0) == cards.DOWN + + +class TestSparkline: + def test_too_few_points_is_a_noop(self): + img = Image.new("RGB", (100, 40)) + before = img.tobytes() + cards._sparkline(cards.ImageDraw.Draw(img), 0, 0, 90, 30, [1.0], cards.UP) + assert img.tobytes() == before + + def test_flat_series_does_not_divide_by_zero(self): + img = Image.new("RGB", (100, 40)) + cards._sparkline(cards.ImageDraw.Draw(img), 0, 0, 90, 30, [5.0, 5.0, 5.0], cards.UP) + + +class TestFontResolution: + def test_returns_a_font(self): + assert cards._font(24) is not None + + def test_falls_back_when_no_font_dir_exists(self): + cards._font_cache.clear() + with patch.object(cards, "_FONT_DIRS", ("/nonexistent",)): + assert cards._font(24, True) is not None + cards._font_cache.clear() + + +class TestSnapshots: + """Structured returns are additive — the prose paths must be untouched.""" + + def test_weather_snapshot_shape(self): + """The Open-Meteo branch, reached via non-US coords — US locations go to + NWS now (see test_weather_source.py). Patching _fetch_openmeteo alone is + not enough to keep this offline: with US coords it would route to NWS and + make a real call.""" + payload = {"current": {"temperature_2m": 81.0, "apparent_temperature": 84.0, + "weather_code": 3, "wind_speed_10m": 7.0, + "relative_humidity_2m": 55}, + "daily": {"temperature_2m_max": [83.0], "temperature_2m_min": [64.0], + "precipitation_probability_max": [20], + "wind_gusts_10m_max": [12.0]}} + with patch.object(weather_mod, "_geocode", return_value=(48.86, 2.35, "Paris")), \ + patch.object(weather_mod, "_fetch_openmeteo", return_value=payload): + s = weather_mod.weather_snapshot("Paris") + assert s["temp_now"] == 81.0 and s["high"] == 83.0 and s["weather_code"] == 3 + assert s["description"] + + def test_weather_snapshot_returns_none_on_failure(self): + with patch.object(weather_mod, "_geocode", side_effect=RuntimeError("boom")): + assert weather_mod.weather_snapshot("Nowhere") is None + + def test_traffic_snapshot_ratio(self): + payload = {"routes": [{"summary": {"travelTimeInSeconds": 1020, + "noTrafficTravelTimeInSeconds": 960, + "trafficDelayInSeconds": 60, + "lengthInMeters": 22000}}]} + with patch.object(traffic_mod, "_geocode_address", return_value=(38.5, -90.4)), \ + patch.object(traffic_mod, "_http_get_json", return_value=payload): + s = traffic_mod.traffic_snapshot("a street", "b street") + assert s["live_min"] == 17 and s["free_min"] == 16 + assert round(s["ratio"], 2) == 1.06, "ratio is what the meter renders" + + def test_traffic_snapshot_none_on_bad_route(self): + with patch.object(traffic_mod, "_geocode_address", return_value=(1, 2)), \ + patch.object(traffic_mod, "_http_get_json", return_value={"routes": []}): + assert traffic_mod.traffic_snapshot("a", "b") is None + + def test_price_snapshot_crypto(self): + resp = MagicMock() + resp.json.return_value = {"bitcoin": {"usd": 77752.0, "usd_24h_change": 1.1, + "usd_7d_change": 0.4}} + resp.raise_for_status = lambda: None + chart = MagicMock() + chart.json.return_value = {"prices": [[0, 76000], [1, 77752]]} + chart.raise_for_status = lambda: None + with patch.object(datafeeds, "_requests") as rq: + rq.get.side_effect = [resp, chart] + s = datafeeds.price_snapshot("bitcoin") + assert s["price"] == 77752.0 and s["is_crypto"] is True + assert len(s["series"]) == 2 + + def test_price_snapshot_crypto_symbol_is_the_real_coingecko_id(self): + """`asset` here is the alias a topic matched on ("avax"), not the + coingecko id. The page builds coingecko.com links from `symbol`, so it + must carry the resolved id ("avalanche-2"), not the alias — a link + built from the alias 404s.""" + resp = MagicMock() + resp.json.return_value = {"avalanche-2": {"usd": 20.0, "usd_24h_change": 1.0}} + resp.raise_for_status = lambda: None + with patch.object(datafeeds, "_requests") as rq: + rq.get.side_effect = [resp, RuntimeError("chart down")] + s = datafeeds.price_snapshot("avax", "Avalanche") + assert s["symbol"] == "avalanche-2" + + def test_price_snapshot_stock_symbol_is_the_real_ticker(self): + """An index's `label` is a human string ("S&P 500"); `symbol` must stay + the actual Yahoo ticker ("^GSPC") so the page's link works.""" + ticker = MagicMock() + ticker.fast_info.last_price = 5000.0 + ticker.history.return_value.empty = True + with patch("yfinance.Ticker", return_value=ticker): + s = datafeeds.price_snapshot("^GSPC", "S&P 500") + assert s["symbol"] == "^GSPC" and s["label"] == "S&P 500" + + def test_price_snapshot_survives_missing_sparkline(self): + resp = MagicMock() + resp.json.return_value = {"bitcoin": {"usd": 100.0, "usd_24h_change": 0.0}} + resp.raise_for_status = lambda: None + with patch.object(datafeeds, "_requests") as rq: + rq.get.side_effect = [resp, RuntimeError("chart down")] + s = datafeeds.price_snapshot("bitcoin") + assert s["price"] == 100.0 and s["series"] == [], "sparkline is a bonus, not a dependency" + + def test_price_snapshot_none_on_failure(self): + with patch.object(datafeeds, "_requests") as rq: + rq.get.side_effect = RuntimeError("boom") + assert datafeeds.price_snapshot("bitcoin") is None + + +# ============================================================================ +# from test_my_page_tool.py +# ============================================================================ +# +# get_my_page — Palmer hands over the user's link mid-conversation. +# +# The page is only useful if it can be asked for. Before this, the URL went out +# once a day with the morning update and there was no way to get it back short of +# scrolling the thread. +# +# The tool returns the URL and the model writes the sentence around it, so the +# tests that matter are: the tool exists and is routed, dispatch returns a live +# URL rather than a stale or invented one, and the no-APP_URL case tells the model +# to shut up about the page instead of promising a link it can't send. + +URL = "https://palmer.example.com/h/AbC123xyz" + + +class TestSchema: + def test_the_tool_exists(self): + assert tool_by_name("get_my_page") is not None + + def test_it_takes_no_arguments(self): + """The caller is the user. There is nothing to pass and nothing to + get wrong — in particular no phone number the model could invent.""" + schema = tool_by_name("get_my_page")["input_schema"] + assert schema["properties"] == {} and schema["required"] == [] + + def test_the_description_covers_how_people_actually_ask(self): + d = tool_by_name("get_my_page")["description"].lower() + for phrase in ("send me my page", "link", "dashboard", "resend"): + assert phrase in d + + def test_the_description_pins_the_url_to_the_end(self): + d = tool_by_name("get_my_page")["description"].lower() + assert "end" in d and "preview" in d + + def test_it_is_routed_in_the_system_prompt(self): + """Tool routing is strict in this codebase — a tool the prompt does not + name is a tool the model will not reliably reach for.""" + assert "get_my_page" in prompts.SYSTEM_PROMPT + + def test_the_prompt_forbids_typing_a_url_from_memory(self): + block = prompts.SYSTEM_PROMPT.split("get_my_page:")[1].split("\n")[0].lower() + assert "never type" in block or "from memory" in block + + +def _drive_my_page(reply="here you go", url=URL): + """Run get_reply through one get_my_page tool call and capture what the + model was handed back.""" + text, result, (ensure,) = drive_tool( + "get_my_page", {}, message="send me my page", reply=reply, + profile={"timezone": "America/Chicago"}, + patches=[patch("palmer.home.ensure_fresh", return_value=url)]) + return text, result, ensure + + +class TestDispatch: + def test_it_returns_the_live_url(self): + _, result, _ = _drive_my_page() + assert URL in result + + def test_it_refreshes_the_page_for_this_caller(self): + """ensure_fresh, never a bare URL builder — a link to a 404 or to yesterday's data + is worse than no link.""" + _, _, ensure = _drive_my_page() + ensure.assert_called_once_with("+1555") + + def test_it_tells_the_model_where_to_put_the_url(self): + _, result, _ = _drive_my_page() + assert "end of your reply" in result.lower() + + def test_the_reply_reaches_the_user(self): + text, _, _ = _drive_my_page(reply=f"all yours {URL}") + assert text.endswith(URL) + + def test_no_app_url_does_not_promise_a_link(self): + _, result, _ = _drive_my_page(url="/h/tok") + assert URL not in result + assert "not mention a page" in result.lower() + + def test_no_app_url_still_answers_instead_of_erroring(self): + text, _, _ = _drive_my_page(reply="not much going on", url="/h/tok") + assert text == "not much going on" + + +class TestPriceTopicNormalization: + """"add Nvidia to my site" has to end up as something the Markets section + can resolve. The drafting model often writes the ticker itself and + sometimes doesn't, and the failure was silent — the topic appeared under + "Watching" with no price anywhere.""" + + def test_a_topic_the_map_already_covers_is_untouched(self): + """No model call: it already resolves.""" + with patch("palmer.llm.client") as client: + assert agent._normalize_price_topic("Nvidia stock") == "Nvidia stock" + client.messages.create.assert_not_called() + + def test_an_unmapped_company_gains_its_ticker(self): + with patch("palmer.tickers.resolve_company_ticker", return_value="LULU"): + assert agent._normalize_price_topic("Lululemon shares") == "Lululemon shares (LULU)" + + def test_an_unverifiable_company_gains_nothing(self): + """No ticker is appended when nothing tradeable can be confirmed.""" + with patch("palmer.tickers.resolve_company_ticker", return_value=None): + assert agent._normalize_price_topic("Stripe stock") == "Stripe stock" + + def test_a_news_topic_never_pays_for_a_lookup(self): + with patch("palmer.llm.client") as client: + for t in ("AI news", "St. Louis Cardinals", "Kirkwood weather"): + assert agent._normalize_price_topic(t) == t + client.messages.create.assert_not_called() + + def test_an_unresolvable_topic_is_left_alone(self): + with patch("palmer.tickers.resolve_company_ticker", return_value=None): + assert agent._normalize_price_topic("some obscure stock") == "some obscure stock" + + def test_empty_input_is_safe(self): + assert agent._normalize_price_topic("") == "" + + def test_it_runs_on_the_add_path(self): + """Normalization has to happen where topics are SAVED, since the read + path runs on every page view and must stay free.""" + import inspect + src = inspect.getsource(agent.get_reply) + block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] + assert "_normalize_price_topic" in block + + +class TestAddingToTheSiteRefreshesIt: + """The morning list and the page are one list, so a topic change has to be + visible on the page immediately — not after the 5-minute price cooldown.""" + + def test_the_add_path_expires_the_price_cache(self): + import inspect + src = inspect.getsource(agent.get_reply) + block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] + assert "invalidate" in block, "a topic change must expire the cached prices" + + def test_a_failure_to_invalidate_does_not_break_the_reply(self): + import inspect + src = inspect.getsource(agent.get_reply) + block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] + assert "except" in block, "the reply matters more than the cache stamp" + + def test_the_tool_description_covers_site_vocabulary(self): + from palmer.tools_def import TOOLS + d = next(t for t in TOOLS if t["name"] == "update_morning_briefing")["description"].lower() + for word in ("markets", "site", "page", "morning"): + assert word in d, f"users say {word!r} and mean this tool" + + def test_the_prompt_separates_asking_a_price_from_tracking_one(self): + from palmer import prompts + block = prompts.SYSTEM_PROMPT.lower() + assert "one-off" in block and "update_morning_briefing" in block + + +class TestPalmerCanSeeTheReminders: + """Watches and price watches were both listed in the system prompt. + Reminders — the one thing the user explicitly asked to happen at a named + time — were the table the model could not read at all. So "what have I got + on today" had nothing to answer from, and "cancel my 4pm one" was a guess + against twenty messages of history, against a tool that deletes.""" + + PHONE = "+15550009999" + + def _system(self, pending): + from unittest.mock import patch + from palmer import agent + with patch.object(agent, "get_profile", + return_value={"timezone": "America/Chicago"}), \ + patch("palmer.db.get_pending_reminders", return_value=pending), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + return agent._build_system(self.PHONE) + + def test_pending_reminders_reach_the_prompt(self): + out = self._system([{"id": 1, "text": "move the car", + "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) + assert "move the car" in out + + def test_they_are_shown_on_the_users_clock(self): + """21:00Z is 4pm in Chicago. Showing UTC is how a confirmation ends up + naming an hour the user never said.""" + out = self._system([{"id": 1, "text": "move the car", + "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) + assert "4:00 PM" in out + assert "21:00" not in out + + def test_a_repeat_says_so(self): + out = self._system([{"id": 1, "text": "take the bins out", + "due_at": "2026-09-04T21:00:00+00:00", "recurrence": "weekly"}]) + assert "repeats weekly" in out + + def test_the_bulk_cancel_hazard_is_stated(self): + out = self._system([{"id": 1, "text": "move the car", + "due_at": "2026-09-04T21:00:00+00:00", "recurrence": None}]) + assert "no text_match takes" in out + assert "ask which" in out + + def test_no_reminders_adds_nothing(self): + assert "Reminders they have set" not in self._system([]) + + def test_a_failed_read_does_not_break_the_turn(self): + from unittest.mock import patch + from palmer import agent + with patch.object(agent, "get_profile", return_value={}), \ + patch("palmer.db.get_pending_reminders", side_effect=RuntimeError("db down")), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + assert agent._build_system(self.PHONE) + + +class TestACancelSaysWhatItTook: + """cancel_reminders returned a bare count on a tool whose no-argument form + deletes every pending reminder, and whose text_match is a substring — so + "call" takes "call mom" and "call the vet" together.""" + + PHONE = "+15550008888" + + def _seed(self): + from palmer import db + db.cancel_reminders(self.PHONE) + for text in ("call mom", "call the vet", "move the car"): + db.save_reminder(self.PHONE, text, "2099-01-01T12:00:00+00:00") + + def test_it_returns_the_texts_that_went(self): + from palmer import db + self._seed() + gone = db.cancel_reminders_named(self.PHONE, "call") + assert sorted(gone) == ["call mom", "call the vet"] + + def test_the_unmatched_one_survives(self): + from palmer import db + self._seed() + db.cancel_reminders_named(self.PHONE, "call") + left = [r["text"] for r in db.get_pending_reminders(self.PHONE)] + assert left == ["move the car"] + + def test_the_count_form_still_works(self): + from palmer import db + self._seed() + assert db.cancel_reminders(self.PHONE) == 3 + + def test_the_dispatch_names_them(self): + import inspect + from palmer import agent + block = inspect.getsource(agent.get_reply).split('"cancel_reminders"')[1] \ + .split("elif b.name")[0] + assert "cancel_reminders_named" in block + assert "Say which ones went" in block + + +# ============================================================================ +# from test_arrange_page_tool.py +# ============================================================================ +# +# arrange_page dispatch: presentation prefs merge by set arithmetic (a delta, +# never a model-restated whole set), unknown section words are surfaced rather +# than guessed, and the prices cache is expired only when the SORT changed — +# order and visibility are render-time and need no invalidate. + +def _drive_arrange(tool_input, profile=None): + _, result, (upsert, invalidate) = drive_tool( + "arrange_page", tool_input, message="arrange my page", profile=profile or {}, + patches=[patch.object(agent, "upsert_profile"), patch("palmer.home.invalidate")]) + saved = upsert.call_args[0][1]["morning_prefs"] if upsert.called else None + return result, saved, invalidate + + +class TestMarketsSort: + def test_movers_is_stored_and_expires_the_prices_cache(self): + result, saved, invalidate = _drive_arrange({"markets_sort": "movers"}) + assert saved["markets_sort"] == "movers" + invalidate.assert_called_once_with("+1555", ("prices",)) + + def test_added_clears_the_key_rather_than_storing_a_default(self): + """Absent means topic order already; a stored default is prompt noise.""" + _, saved, invalidate = _drive_arrange({"markets_sort": "added"}, + {"morning_prefs": {"markets_sort": "movers"}}) + assert "markets_sort" not in saved + invalidate.assert_called_once_with("+1555", ("prices",)) + + def test_restating_the_current_sort_does_not_expire_the_cache(self): + _, saved, invalidate = _drive_arrange({"markets_sort": "movers"}, + {"morning_prefs": {"markets_sort": "movers"}}) + invalidate.assert_not_called() + + +class TestOrderAndVisibility: + def test_order_words_are_canonicalized(self): + _, saved, invalidate = _drive_arrange({"section_order": ["stocks", "headlines"]}) + assert saved["section_order"] == ["markets", "news"] + + def test_order_changes_do_not_expire_any_cache(self): + """Render-time — carried onto the payload on every view.""" + _, _, invalidate = _drive_arrange({"section_order": ["markets"]}) + invalidate.assert_not_called() + + def test_hide_then_show_round_trips(self): + _, saved, _ = _drive_arrange({"hide": ["traffic"]}) + assert saved["hidden_sections"] == ["commute"] + _, saved, _ = _drive_arrange({"show": ["commute"]}, + {"morning_prefs": {"hidden_sections": ["commute"]}}) + assert saved["hidden_sections"] == [] + + def test_hide_is_a_delta_not_a_restatement(self): + """An existing hidden section survives a hide it wasn't named in.""" + _, saved, _ = _drive_arrange({"hide": ["news"]}, + {"morning_prefs": {"hidden_sections": ["commute"]}}) + assert set(saved["hidden_sections"]) == {"commute", "news"} + + def test_other_prefs_survive_the_merge(self): + _, saved, _ = _drive_arrange({"hide": ["news"]}, + {"morning_prefs": {"episode_alerts": True, + "opening_kinds": ["local"]}}) + assert saved["episode_alerts"] is True + assert saved["opening_kinds"] == ["local"] + + +class TestUnknownWords: + def test_an_unknown_word_is_surfaced_not_guessed(self): + result, saved, _ = _drive_arrange({"hide": ["horoscope"]}) + assert "horoscope" in result + assert "ask" in result.lower() + assert saved is None, "nothing recognizable, nothing written" + + def test_kind_words_do_not_hide_the_opening_section(self): + """'movies' and 'concerts' are Opening KINDS (opening_remove's job); + mapping them here would let 'hide movies' silently hide the whole + section instead of trimming a kind.""" + result, saved, _ = _drive_arrange({"hide": ["movies"]}) + assert saved is None + assert "movies" in result + + class TestPreviewCard: def test_it_names_the_site_and_carries_an_icon(self): html = _render(name="Jeff") @@ -387,8 +1033,7 @@ def test_it_names_the_site_and_carries_an_icon(self): def test_the_description_leads_with_the_weather_when_named(self): desc = re.search(r'og:description" content="(.*?)"', _render(name="Jeff")).group(1) - assert desc.startswith("81\u00b0") + assert desc.startswith("81°") def test_the_icon_is_a_png(self): - from palmer import cards assert cards.render_icon()[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/tests/test_phantom_history.py b/tests/test_phantom_history.py deleted file mode 100644 index 34acd86..0000000 --- a/tests/test_phantom_history.py +++ /dev/null @@ -1,108 +0,0 @@ -"""What Palmer records must be what Palmer sent. - -Four senders got this wrong in two different ways, and both produce the same -symptom the user sees: Palmer referring to a message that never arrived, or -repeating one that did. - -watches.py (and the since-retired alerts job) called send_sms and ignored the -result, then saved unconditionally. send_sms returns False on a Twilio failure AND on a -leaks_deliberation block, so history accumulated messages nobody received — -and _build_system feeds history straight back to the model. - -shopping.py and flightwatch.py never called save_message at all. So a user -replying "how much?" to a price alert got an answer with no referent, and each -sender's own _is_duplicate_subject check — which reads assistant messages — -could never see its own repeats. -""" -from unittest.mock import patch - -from palmer import db - - -PHONE = "+15550001111" - - -def _fresh(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_phantom.db") - db.init_db() - - -class TestTheKindColumn: - def test_a_kind_round_trips(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_message(PHONE, "assistant", "morning line", kind="morning") - conn = db._conn() - cur = conn.cursor() - cur.execute(f"SELECT kind FROM messages WHERE phone = {db.PH}", (PHONE,)) - assert cur.fetchone()["kind"] == "morning" - conn.close() - - def test_kind_is_optional_so_existing_callers_are_unaffected(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_message(PHONE, "user", "hey") - assert db.get_history(PHONE) == [{"role": "user", "content": "hey"}] - - def test_migration_is_idempotent(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.init_db() - db.init_db() - db.save_message(PHONE, "assistant", "x", kind="alert") - assert len(db.get_history(PHONE)) == 1 - - -class TestPriceAlertsReachHistory: - """run_price_watches imports its db helpers inside the function, so they are - patched at their source rather than on the shopping module.""" - - def _run(self, tmp_path, monkeypatch, sent: bool): - _fresh(tmp_path, monkeypatch) - from palmer import shopping - db.save_price_watch(PHONE, "Nike Pegasus 40", None) - w = dict(db.get_user_price_watches(PHONE)[0]) - w["baseline_price"] = 120.0 - w["cooldown_hours"] = 12 - w["phone"] = PHONE # get_user_price_watches scopes by phone and omits it - with patch.object(db, "get_active_price_watches", return_value=[w]), \ - patch.object(db, "claim_price_watch_alert", return_value=True), \ - patch.object(db, "update_price_watch_alerted"), \ - patch.object(db, "release_price_watch_claim"), \ - patch.object(shopping, "check_price", return_value={ - "price": 90.0, "url": "https://m.example/x", "merchant": "Nike"}), \ - patch.object(shopping, "_draft_alert", return_value="pegasus dropped to $90"), \ - patch("palmer.userprofile._is_duplicate_subject", return_value=False), \ - patch("palmer.sms_util.send_sms", return_value=sent): - shopping.run_price_watches() - return [m for m in db.get_history(PHONE) if m["role"] == "assistant"] - - def test_a_sent_alert_is_recorded(self, tmp_path, monkeypatch): - rows = self._run(tmp_path, monkeypatch, sent=True) - assert any("pegasus" in m["content"].lower() for m in rows), rows - - def test_a_failed_price_alert_is_not_recorded(self, tmp_path, monkeypatch): - assert self._run(tmp_path, monkeypatch, sent=False) == [] - - def test_the_alert_is_visible_to_the_next_duplicate_check(self, tmp_path, monkeypatch): - """The point of recording it: _is_duplicate_subject reads assistant - messages, so before this the sender could never see its own repeats.""" - from datetime import datetime, timedelta, timezone - self._run(tmp_path, monkeypatch, sent=True) - cutoff = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() - assert db.get_recent_assistant_messages(PHONE, cutoff) - - -class TestProactiveSendersNeverShipTheFallbackString: - def test_no_proactive_sender_calls_ensure_sms(self): - """ensure_sms's last resort is FALLBACK_SMS. That is right for a reply - the user is waiting on and wrong for anything unprompted — a failed - price check texted 'something went sideways on my end, try again' to - someone who had asked for nothing.""" - import pathlib - import palmer - pkg = pathlib.Path(palmer.__file__).parent - proactive = ["watches.py", "followup.py", "morning.py", "scorewatch.py", - "shopping.py", "flightwatch.py", "reminders.py"] - for name in proactive: - src = (pkg / name).read_text() - calls = [ln for ln in src.splitlines() - if "ensure_sms(" in ln and not ln.strip().startswith("#")] - assert not calls, (name, calls) diff --git a/tests/test_price_alert.py b/tests/test_price_alert.py deleted file mode 100644 index 9dfaded..0000000 --- a/tests/test_price_alert.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for the shared price-alert drafter. - -shopping.py and amazon.py used to carry near-identical copies of this. These -pin the behavior that has to stay identical across both sources, and the two -places they legitimately differ (the price line, and whether a URL is appended). -""" -from unittest.mock import patch, MagicMock - -from palmer import price_alert - - -def _resp(text: str) -> MagicMock: - block = MagicMock() - block.text = text - r = MagicMock() - r.content = [block] - return r - - -CURRENT = {"price": 42.0, "merchant": "Zappos", "url": "https://www.amazon.com/dp/B0PROT"} -WATCH = {"phone": "+15550001111", "target_price": None, "baseline_price": 60.0} - - -class TestContextFacts: - def test_drop_states_dollars_and_percentage(self): - # Dollars lead — the $2 materiality rule is denominated in dollars. - ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", None) - assert "Down $18.00 (about 30%)" in ctx and "$60.00" in ctx - - def test_rise_says_up_not_down(self): - # A rise must never be described as a drop; $60 -> $63.50 is +$3.50. - ctx = price_alert._context("Protein", {"price": 63.50, "merchant": "Zappos"}, - WATCH, "rise", None) - assert "Up $3.50 (about 6%)" in ctx - assert "Down" not in ctx - - def test_target_hit(self): - watch = dict(WATCH, target_price=45.0) - ctx = price_alert._context("Protein", CURRENT, watch, "target", None) - assert "at or under $45.00" in ctx - - def test_shopping_price_line_names_the_merchant(self): - ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", None) - assert "Zappos" in ctx - - def test_amazon_price_line_names_the_source(self): - ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", "Amazon") - assert "Amazon price: $42.00" in ctx - - def test_missing_merchant_does_not_blow_up(self): - ctx = price_alert._context("Protein", {"price": 42.0}, WATCH, "drop", None) - assert "unknown seller" in ctx - - def test_no_baseline_no_percentage(self): - watch = dict(WATCH, baseline_price=None) - ctx = price_alert._context("Protein", CURRENT, watch, "drop", None) - assert "Down" not in ctx - - def test_rise_prompt_does_not_claim_the_watch_hit(self): - # "your price watch just hit" on a price INCREASE reads as good news. - from unittest.mock import patch, MagicMock - seen = {} - def _capture(**kw): - seen.update(kw) - m = MagicMock(); m.content = [MagicMock(text="shake went up to 63.50")] - return m - with patch("palmer.price_alert.client.messages.create", side_effect=_capture), \ - patch("palmer.price_alert._build_system", return_value="sys"): - price_alert.draft_price_alert("Protein", {"price": 63.50, "merchant": "Z"}, - WATCH, "rise") - prompt = seen["messages"][0]["content"] - assert "just hit" not in prompt - assert "went UP" in prompt - - -class TestDrafting: - def _draft(self, **kw): - with patch.object(price_alert, "_build_system", return_value="SYS"), \ - patch.object(price_alert.client.messages, "create", - return_value=_resp("protein's down to $42")) as create: - body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop", **kw) - return body, create - - def test_no_link_by_default(self): - body, _ = self._draft() - assert body == "protein's down to $42" - - def test_link_appended_when_given(self): - body, _ = self._draft(link=CURRENT["url"]) - assert body.endswith(CURRENT["url"]) - - def test_uses_sonnet_not_haiku(self): - """User-facing drafting belongs on Sonnet per CLAUDE.md routing.""" - _, create = self._draft() - assert create.call_args.kwargs["model"] == price_alert.SONNET_MODEL - - def test_uses_the_real_system_prompt(self): - """The whole point: price alerts sound like the Palmer they talk to.""" - _, create = self._draft() - assert create.call_args.kwargs["system"] == "SYS" - - def test_prompt_suppresses_url_when_one_is_appended(self): - _, create = self._draft(link=CURRENT["url"]) - assert "Do NOT include a URL" in create.call_args.kwargs["messages"][0]["content"] - - def test_falls_back_to_the_base_prompt_without_a_phone(self): - """A malformed row shouldn't cost the user their alert.""" - with patch.object(price_alert, "_build_system") as bs, \ - patch.object(price_alert.client.messages, "create", return_value=_resp("x")) as create: - price_alert.draft_price_alert("Protein", CURRENT, {"baseline_price": 60.0}, "drop") - bs.assert_not_called() - # Used to assert NO system prompt at all, which meant a failed profile - # read dropped the voice, the calibration and every NEVER rule — the - # anti-redirect one included — from a message that still went out. - sys = create.call_args.kwargs.get("system") or "" - assert "Palmer" in sys, "must still sound like Palmer" - assert "{profile_block}" not in sys, "the template must be formatted, not raw" - - -class TestFailsSafe: - def test_model_failure_falls_back_to_facts(self): - with patch.object(price_alert, "_build_system", return_value="SYS"), \ - patch.object(price_alert.client.messages, "create", side_effect=RuntimeError("boom")): - body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") - assert "42" in body and "Protein" in body - - def test_fallback_still_appends_the_link(self): - with patch.object(price_alert, "_build_system", return_value="SYS"), \ - patch.object(price_alert.client.messages, "create", side_effect=RuntimeError("boom")): - body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop", - link=CURRENT["url"], source_label="Amazon") - assert body.endswith(CURRENT["url"]) - assert "Amazon" in body - - def test_empty_model_output_falls_back(self): - with patch.object(price_alert, "_build_system", return_value="SYS"), \ - patch.object(price_alert.client.messages, "create", return_value=_resp(" ")): - body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") - assert body.strip(), "must never send an empty alert" - - def test_build_system_failure_does_not_lose_the_alert(self): - with patch.object(price_alert, "_build_system", side_effect=RuntimeError("db down")), \ - patch.object(price_alert.client.messages, "create", return_value=_resp("x")): - body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") - assert body.strip() - - -class TestBothSourcesDelegate: - def test_shopping_passes_no_link(self): - from palmer import shopping - with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: - shopping._draft_alert("Protein", CURRENT, WATCH, "drop") - assert d.call_args.kwargs.get("link") is None - - def test_amazon_passes_link_and_label(self): - from palmer import amazon - with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: - amazon.draft_alert("Protein", CURRENT, WATCH, "drop") - assert d.call_args.kwargs["link"] == CURRENT["url"] - assert d.call_args.kwargs["source_label"] == "Amazon" diff --git a/tests/test_price_daily_cap.py b/tests/test_price_daily_cap.py deleted file mode 100644 index e61b935..0000000 --- a/tests/test_price_daily_cap.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for the per-watch daily cap and marketplace-filter added to price watches. -The cap prevents a price oscillating across the drop threshold from re-firing -indefinitely on the 12-hour cadence.""" -from datetime import date, timedelta -from unittest.mock import patch - -from palmer.shopping import _daily_ok, _is_marketplace_thirdparty, PRICE_DAILY_ALERT_MAX, check_price - - -class TestDailyOk: - def test_no_history_ok(self): - assert _daily_ok({"daily_alert_count": 0, "daily_alert_date": None}) - - def test_prior_day_resets(self): - yesterday = (date.today() - timedelta(days=1)).isoformat() - assert _daily_ok({"daily_alert_count": 99, "daily_alert_date": yesterday}) - - def test_under_cap_today_ok(self): - today = date.today().isoformat() - assert _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX - 1, - "daily_alert_date": today}) - - def test_at_cap_today_blocked(self): - today = date.today().isoformat() - assert not _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX, - "daily_alert_date": today}) - - def test_over_cap_today_blocked(self): - today = date.today().isoformat() - assert not _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX + 5, - "daily_alert_date": today}) - - -class TestMarketplaceExtended: - def test_walmart_marketplace_seller_filtered(self): - assert _is_marketplace_thirdparty("Walmart - Greensole LLC") - - def test_walmart_bare_still_ok(self): - assert not _is_marketplace_thirdparty("Walmart") - - def test_stockx_goat_flagged(self): - assert _is_marketplace_thirdparty("StockX") - assert _is_marketplace_thirdparty("GOAT") - - -class TestCheckPriceStripsMarketplace: - """The AirPods-at-Poshmark case: cheapest listing is a resale, but check_price - should feed only real retailers to _pick_best_match.""" - - def test_poshmark_dropped_before_pick(self): - results = [ - {"title": "AirPods Pro 2", "price": 75.0, "merchant": "Poshmark", - "url": "https://example.com/1"}, - {"title": "Apple AirPods Pro 2", "price": 200.0, "merchant": "Apple", - "url": "https://example.com/2"}, - {"title": "AirPods Pro 2", "price": 100.0, "merchant": "eBay - randomseller", - "url": "https://example.com/3"}, - ] - seen = [] - - def _capture(*args, **kwargs): - seen.extend(args[1] if len(args) > 1 else kwargs.get("results", [])) - return None - - with patch("palmer.shopping._serpapi_search", return_value=results), \ - patch("palmer.shopping._pick_best_match", side_effect=_capture) as mock_pick: - check_price("AirPods Pro 2") - - mock_pick.assert_called_once() - passed = mock_pick.call_args.args[1] - merchants = {r["merchant"] for r in passed} - assert merchants == {"Apple"}, f"expected only real retailer, got {merchants}" diff --git a/tests/test_price_watches.py b/tests/test_price_watches.py deleted file mode 100644 index b8ce724..0000000 --- a/tests/test_price_watches.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Tests for price-watch alert logic. Pure logic only — no SerpAPI / Anthropic -calls. Run: pytest test_price_watches.py""" -from dotenv import load_dotenv -load_dotenv() - -from datetime import datetime, timezone, timedelta - -from unittest.mock import patch - -from palmer.shopping import ( - _cooldown_ok, _should_alert, MOVE_MIN_ABS, _filter_and_sort, - _is_marketplace_thirdparty, _brand_tokens, browse_shop, -) - - -def _watch(**kwargs) -> dict: - """Build a watch dict with sensible defaults; override fields per-test.""" - base = { - "id": 1, - "phone": "+15551234567", - "product_name": "Nike Pegasus 40", - "target_price": None, - "currency": "USD", - "baseline_price": None, - "last_seen_price": None, - "cooldown_hours": 12, - "last_alerted": None, - } - base.update(kwargs) - return base - - -class TestShouldAlert: - def test_no_baseline_no_target_never_alerts(self): - assert _should_alert(_watch(), 50.0) == "" - - def test_target_hit_alerts(self): - assert _should_alert(_watch(target_price=100.0), 100.0) == "target" - assert _should_alert(_watch(target_price=100.0), 99.99) == "target" - - def test_target_not_hit_no_alert(self): - assert _should_alert(_watch(target_price=100.0), 100.01) == "" - - def test_drop_over_the_bar_alerts(self): - assert _should_alert(_watch(baseline_price=50.98), 47.98) == "drop" - assert _should_alert(_watch(baseline_price=200.0), 150.0) == "drop" - - def test_move_under_the_bar_is_silent(self): - assert _should_alert(_watch(baseline_price=50.98), 49.50) == "" - assert _should_alert(_watch(baseline_price=200.0), 198.50) == "" - assert _should_alert(_watch(baseline_price=200.0), 201.50) == "" - - def test_bar_is_flat_not_proportional(self): - # The rule is "$2 on any product", so an expensive item is NOT held to a - # bigger move. A percentage bar (briefly max(5%, $2)) needed $10 here. - assert _should_alert(_watch(baseline_price=200.0), 197.50) == "drop" - assert _should_alert(_watch(baseline_price=1200.0), 1197.50) == "drop" - # ...and a cheap item clears it on the same absolute move. - assert _should_alert(_watch(baseline_price=12.00), 9.50) == "drop" - - def test_rise_over_the_bar_alerts(self): - # A material move UP is worth a text too — it's the cue to buy now. - assert _should_alert(_watch(baseline_price=50.98), 53.98) == "rise" - assert _should_alert(_watch(baseline_price=200.0), 202.50) == "rise" - - def test_boundary_is_inclusive_both_ways(self): - assert _should_alert(_watch(baseline_price=100.0), 98.00) == "drop" - assert _should_alert(_watch(baseline_price=100.0), 102.00) == "rise" - assert _should_alert(_watch(baseline_price=100.0), 98.01) == "" - assert _should_alert(_watch(baseline_price=100.0), 101.99) == "" - - def test_target_takes_precedence_over_drop(self): - # Both would trigger; expect 'target' since that's the user's explicit ask - assert _should_alert(_watch(target_price=150.0, baseline_price=200.0), 140.0) == "target" - - def test_a_rise_never_reports_as_a_target_hit(self): - # target is a ceiling: a price above it must not read as "you got it". - assert _should_alert(_watch(target_price=45.0, baseline_price=50.0), 55.0) == "rise" - - def test_move_constant_is_reasonable(self): - assert 0.0 < MOVE_MIN_ABS < 10.0 # a materiality floor, in dollars - - -class TestCooldownOk: - def test_never_alerted_ok(self): - assert _cooldown_ok(_watch(last_alerted=None)) - - def test_recent_alert_blocked(self): - recent = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() - assert not _cooldown_ok(_watch(last_alerted=recent, cooldown_hours=12)) - - def test_stale_alert_ok(self): - stale = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() - assert _cooldown_ok(_watch(last_alerted=stale, cooldown_hours=12)) - - def test_exactly_at_cooldown_ok(self): - now = datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc) - past = (now - timedelta(hours=12)).isoformat() - assert _cooldown_ok(_watch(last_alerted=past, cooldown_hours=12), now=now) - - def test_malformed_timestamp_treated_as_ok(self): - # Defensive: don't block forever on bad data - assert _cooldown_ok(_watch(last_alerted="not-a-date")) - - def test_z_suffix_iso_parses(self): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") - assert not _cooldown_ok(_watch(last_alerted=past, cooldown_hours=12)) - - -class TestFilterAndSort: - def _sample(self) -> list[dict]: - return [ - {"title": "Reebok Classic Leather", "price": 92.0, "merchant": "Zappos", "url": ""}, - {"title": "Reebok Nano X4", "price": 140.0, "merchant": "reebok.com", "url": ""}, - {"title": "Reebok Club C 85", "price": 75.0, "merchant": "Amazon", "url": ""}, - {"title": "Reebok Floatride", "price": 180.0, "merchant": "Nordstrom", "url": ""}, - {"title": "Reebok kids shoe", "price": 35.0, "merchant": "Target", "url": ""}, - ] - - def test_sorts_cheapest_first(self): - out = _filter_and_sort(self._sample(), None, None, 10) - prices = [r["price"] for r in out] - assert prices == sorted(prices) - - def test_max_price_filter(self): - out = _filter_and_sort(self._sample(), max_price=100.0, min_price=None, limit=10) - assert all(r["price"] <= 100.0 for r in out) - assert len(out) == 3 # 35, 75, 92 - - def test_min_price_filter(self): - out = _filter_and_sort(self._sample(), max_price=None, min_price=100.0, limit=10) - assert all(r["price"] >= 100.0 for r in out) - assert len(out) == 2 # 140, 180 - - def test_band_filter(self): - out = _filter_and_sort(self._sample(), max_price=150.0, min_price=70.0, limit=10) - assert [r["price"] for r in out] == [75.0, 92.0, 140.0] - - def test_limit_caps_results(self): - out = _filter_and_sort(self._sample(), None, None, 2) - assert len(out) == 2 - assert [r["price"] for r in out] == [35.0, 75.0] - - def test_empty_input(self): - assert _filter_and_sort([], None, None, 5) == [] - - -class TestMarketplaceThirdparty: - def test_ebay_individual_seller_is_thirdparty(self): - assert _is_marketplace_thirdparty("eBay - dabondo1") - assert _is_marketplace_thirdparty("eBay - sli00uo0rtoi") - - def test_named_marketplaces_are_thirdparty(self): - for m in ("Poshmark", "Mercari", "Depop", "Vinted", "Grailed"): - assert _is_marketplace_thirdparty(m), m - - def test_real_retailers_not_thirdparty(self): - for m in ("Madewell", "Nordstrom Rack", "Zappos", "Amazon", "shopbop.com", "Allen Edmonds"): - assert not _is_marketplace_thirdparty(m), m - - def test_link_mode_sort_pushes_thirdparty_last(self): - # Google-ordered results: cheap eBay listing shouldn't win link mode - results = [ - {"merchant": "eBay - randomseller", "price": 100.0}, - {"merchant": "Nordstrom Rack", "price": 180.0}, - {"merchant": "shopbop.com", "price": 212.0}, - ] - ranked = sorted(results, key=lambda r: _is_marketplace_thirdparty(r["merchant"])) - # Original order preserved for non-third-party; eBay falls to end - assert ranked[0]["merchant"] == "Nordstrom Rack" - assert ranked[-1]["merchant"].startswith("eBay - ") - - -class TestBrandTokens: - def test_extracts_multi_word_brand(self): - assert "allen" in _brand_tokens("Allen Edmonds Newman Penny Loafer") - assert "edmonds" in _brand_tokens("Allen Edmonds Newman Penny Loafer") - - def test_short_words_ignored(self): - # 'the', 'a', 'is' should not become tokens - tokens = _brand_tokens("the Nike Air") - assert "the" not in tokens - assert "nike" in tokens - - def test_non_alpha_ignored(self): - tokens = _brand_tokens("WH-1000XM5 Sony headphones") - assert "sony" in tokens - assert "headphones" in tokens - # WH-1000XM5 contains non-alpha, should be dropped - assert not any("1000" in t for t in tokens) - - def test_empty_query(self): - assert _brand_tokens("") == [] - - -class TestBaselineWorkflow: - """The baseline-then-alert two-phase flow: first check silently records, - later checks fire. We simulate the state machine at the dict level.""" - - def test_first_check_no_alert_after_baseline_set(self): - w = _watch(baseline_price=None, target_price=None) - # No baseline, no target → run_price_watches would set baseline and NOT alert. - assert _should_alert(w, 200.0) == "" - - def test_second_check_alerts_on_meaningful_drop(self): - # After baseline was set at $200 on tick 1, tick 2 sees $150 → alert - w = _watch(baseline_price=200.0) - assert _should_alert(w, 150.0) == "drop" - - def test_second_check_no_alert_on_noise(self): - # After baseline at $200, tick 2 sees $199.20 — an $0.80 wobble, under - # the flat $2 bar. ($195 used to count as noise here; under a flat bar a - # $5 move on any product is material, which is the point of the change.) - w = _watch(baseline_price=200.0) - assert _should_alert(w, 199.20) == "" - - def test_alerted_price_becomes_the_new_baseline(self): - # update_price_watch_alerted re-baselines to the alerted price, so the - # same discount does not re-qualify on the next tick — only a further - # drop does. Simulated at the dict level, like the rest of this class. - w = _watch(baseline_price=200.0) - assert _should_alert(w, 150.0) == "drop" - w["baseline_price"] = 150.0 # what the alert write now does - assert _should_alert(w, 150.0) == "" # still cheap, but already told - assert _should_alert(w, 147.0) == "drop" # a further $3 move does fire - - -class TestBrowseShop: - """Pure-logic coverage for browse_shop's ranking + aggregator-skip rules. - _http_get_json is mocked so no network calls.""" - - def _serp(self, organic=None, knowledge_graph=None) -> dict: - return { - "organic_results": organic or [], - "knowledge_graph": knowledge_graph or {}, - } - - def test_knowledge_graph_wins_when_brand_matches(self): - payload = self._serp( - organic=[{"title": "Buzzfeed roundup", "link": "https://www.buzzfeed.com/best-tees"}], - knowledge_graph={"title": "Madewell", "website": "https://www.madewell.com"}, - ) - with patch("palmer.serpapi._http_get_json", return_value=payload): - out = browse_shop("Madewell mens tee shirts") - assert "madewell.com" in out - - def test_brand_organic_beats_top_ranked_listicle(self): - payload = self._serp(organic=[ - {"title": "Best T-Shirts 2026", "link": "https://www.buzzfeed.com/tees"}, - {"title": "Men's T-Shirts | Madewell", "link": "https://www.madewell.com/mens/tshirts"}, - ]) - with patch("palmer.serpapi._http_get_json", return_value=payload): - out = browse_shop("Madewell mens tee shirts") - assert "madewell.com/mens/tshirts" in out - assert "buzzfeed" not in out - - def test_aggregators_skipped(self): - # No brand token match; walk past pinterest/reddit/google to real store - payload = self._serp(organic=[ - {"title": "Pinterest pins", "link": "https://www.pinterest.com/x"}, - {"title": "Reddit thread", "link": "https://www.reddit.com/r/malefashionadvice/x"}, - {"title": "Actual store", "link": "https://www.uniqlo.com/us/en/men/tops/t-shirts"}, - ]) - with patch("palmer.serpapi._http_get_json", return_value=payload): - out = browse_shop("mens tee shirts") - assert "uniqlo.com" in out - assert "pinterest" not in out - assert "reddit" not in out - - def test_amazon_search_page_skipped_product_page_allowed(self): - search_page = self._serp(organic=[ - {"title": "Amazon search", "link": "https://www.amazon.com/s?k=wool+coat"}, - {"title": "Wool coat", "link": "https://www.amazon.com/dp/B0XXXX"}, - ]) - with patch("palmer.serpapi._http_get_json", return_value=search_page): - out = browse_shop("wool coat") - assert "/dp/B0XXXX" in out - assert "/s?k=" not in out - - def test_falls_back_to_top_valid_organic_when_no_brand_match(self): - payload = self._serp(organic=[ - {"title": "Random shop", "link": "https://www.someshop.com/x"}, - {"title": "Another shop", "link": "https://www.othershop.com/y"}, - ]) - with patch("palmer.serpapi._http_get_json", return_value=payload): - out = browse_shop("wool coat") - assert "someshop.com" in out - - def test_empty_organic_returns_no_result_string(self): - """Empty, and said as an empty result rather than a dead end. - - This used to assert the literal "No browse result found", which told - the model nothing about what to do next — the shape that gets - paraphrased into a refusal and then into another store.""" - from palmer import guards - with patch("palmer.serpapi._http_get_json", return_value=self._serp()): - out = browse_shop("Madewell tees") - assert "DO have this" in out - assert "confirm the brand" in out - assert not guards.redirects_elsewhere(out) - - def test_serpapi_failure_returns_no_result_string(self): - from palmer import guards - with patch("palmer.serpapi._http_get_json", return_value=None): - out = browse_shop("Madewell tees") - assert "DO have this" in out - assert not guards.redirects_elsewhere(out) - - def test_missing_api_key_returns_unavailable(self): - with patch("palmer.serpapi.API_KEY", ""): - out = browse_shop("Madewell tees") - # The string must not imply the capability does not exist — that is what - # the model paraphrased into "I can't do that, try somewhere else". - low = out.lower() - assert "failed to run" in low and "try again" in low - assert "do have product search" in low - for rival in ("google", "amazon.com", "another store"): - assert f"try {rival}" not in low - - -class TestPriceWatchSchedule: - """run_price_watches must be on a fixed-hour cron, not an interval. - - An interval job's first run is start + interval, and the clock restarts on - every dyno boot — i.e. every deploy. At a 12h cadence that meant the job - only ever ran on days production was left alone for 12 straight hours; on a - day with four deploys it never ran at all, and a tick that finds nothing - logs nothing, so it failed silently. The property under test is that the - fire times are a function of the clock, not of when the process started.""" - - def _trigger(self): - from unittest.mock import patch - with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - from palmer import main - from palmer.shopping import run_price_watches - jobs = [j for j in main._scheduler.get_jobs() if j.func is run_price_watches] - assert len(jobs) == 1, "expected exactly one run_price_watches job" - return jobs[0].trigger - - def test_is_a_cron_trigger(self): - from apscheduler.triggers.cron import CronTrigger - assert isinstance(self._trigger(), CronTrigger) - - def test_phase_is_independent_of_process_start(self): - # The regression an interval trigger would reintroduce: boot at two - # different moments and the schedule must not move. - from datetime import datetime, timedelta, timezone - trigger = self._trigger() - boot_a = datetime(2026, 8, 25, 3, 17, tzinfo=timezone.utc) - boot_b = boot_a + timedelta(hours=5, minutes=42) # a later deploy - assert (trigger.get_next_fire_time(None, boot_a) - == trigger.get_next_fire_time(None, boot_b)) - - def test_runs_exactly_twice_a_day(self): - # The SerpAPI budget is denominated in runs per day, not in evenness of - # spacing — these slots are 16h and 8h apart on purpose (see main.py). - from datetime import datetime, timedelta, timezone - trigger = self._trigger() - start = datetime(2026, 8, 25, 0, 0, tzinfo=timezone.utc) - fire, fires = None, [] - while True: - fire = trigger.get_next_fire_time(fire, start if fire is None else fire + timedelta(seconds=1)) - if fire >= start + timedelta(days=1): - break - fires.append(fire) - assert len(fires) == 2, fires - - def test_fires_during_waking_hours_for_served_timezones(self): - # These are unprompted texts. Both user timezones on record must land in - # daytime, or the cadence is correct and the experience still bad. - from datetime import datetime, timedelta, timezone - from zoneinfo import ZoneInfo - trigger = self._trigger() - fire = None - for _ in range(4): - start = datetime(2026, 8, 25, 1, 0, tzinfo=timezone.utc) if fire is None else fire + timedelta(seconds=1) - fire = trigger.get_next_fire_time(fire, start) - for tz in ("America/Chicago", "America/Los_Angeles"): - hour = fire.astimezone(ZoneInfo(tz)).hour - assert 8 <= hour <= 21, f"{fire} is {hour}:00 in {tz}" diff --git a/tests/test_profile_freshness.py b/tests/test_profile_freshness.py deleted file mode 100644 index 9016924..0000000 --- a/tests/test_profile_freshness.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Stale facts, and the three places they leaked into what Palmer says. - -The whole profile is dumped into every system prompt as CURRENT fact and -nothing in it ever expired. One profile read `city: "Culver City"` three lines -above `life_context: "Based in LA"` — both true when written, and together the -exact contradiction behind a week of Los Angeles temperatures arriving under -the name Culver City. Another still carried `stressed_about: "active fire -emergency in LA area"` long after the fire, and two carried a `follow_up` full -of notes about Palmer's own delivery rather than anything about the person. - -None of that was the model being confused by volume — the system prompt and -tools are about 13k tokens and the profile 1-3k, which is comfortable. It was -the model being told, every turn, things that had stopped being true. - -Three mechanisms here: facts age and then disappear, `city` is declared -authoritative over anything else that names a place, and the card reads the -user's clock rather than the dyno's. -""" -from datetime import date, timedelta -from unittest.mock import patch - -from palmer import agent -from palmer import artifacts -from palmer import home -from palmer import userprofile -from palmer.timeutil import local_today - - -def _aged(field, days, value="something"): - return {"city": "Culver City", "timezone": "America/Los_Angeles", field: value, - "field_dates": {field: (date.today() - timedelta(days=days)).isoformat()}} - - -class TestVolatileFactsAge: - def test_a_fresh_fact_is_shown_plainly(self): - out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", 0)) - assert out["stressed_about"] == "something" - - def test_a_few_days_old_carries_its_date(self): - """The model should be able to tell a live worry from a week-old one.""" - out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", 6)) - assert out["stressed_about"]["value"] == "something" - assert out["stressed_about"]["days_old"] == 6 - - def test_past_its_life_it_disappears(self): - life = userprofile.VOLATILE_FIELDS["stressed_about"] - out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", life + 1)) - assert "stressed_about" not in out - - def test_durable_facts_never_expire(self): - """Name, city, job and relationships do not rot, and dating them would - invite the model to doubt things it should not.""" - old = {"name": "Sam", "city": "Springfield", "job": "teacher", - "field_dates": {"name": "2020-01-01"}} - out = userprofile.fresh_profile_for_prompt(old) - assert out["name"] == "Sam" and out["city"] == "Springfield" - for f in ("name", "city", "job"): - assert f not in userprofile.VOLATILE_FIELDS - - def test_an_unstamped_fact_is_left_alone(self): - """Profiles written before stamping existed must not vanish wholesale.""" - out = userprofile.fresh_profile_for_prompt({"stressed_about": "x", "city": "Y"}) - assert out["stressed_about"] == "x" - - def test_storage_is_untouched(self): - """A fact that went quiet was not wrong — the consolidator may reassert - it tomorrow, so nothing is deleted from the row.""" - p = _aged("stressed_about", 999) - userprofile.fresh_profile_for_prompt(p) - assert p["stressed_about"] == "something" - - def test_writing_a_volatile_field_stamps_it(self): - updates = {"stressed_about": "a deadline"} - userprofile._stamp_volatile({"timezone": "America/Chicago"}, updates) - assert updates["field_dates"]["stressed_about"] == \ - local_today("America/Chicago").isoformat() - - def test_writing_a_durable_field_stamps_nothing(self): - updates = {"name": "Jeff"} - userprofile._stamp_volatile({}, updates) - assert "field_dates" not in updates - - def test_clearing_a_field_does_not_restamp_it(self): - updates = {"stressed_about": None} - userprofile._stamp_volatile({}, updates) - assert "field_dates" not in updates - - -class TestCityIsAuthoritative: - """`city` is the only location any tool reads. Everything else that names a - place is background and may be months out of date.""" - - def test_the_prompt_names_the_city_and_ranks_it(self): - profile = {"city": "Culver City", "life_context": "Based in LA."} - with patch.object(agent, "get_profile", return_value=profile): - sys = agent._build_system("+1555") - assert "Their location is Culver City, full stop" in sys - assert "never pair a number with a place it did not come from" in sys - - def test_no_city_means_no_claim(self): - with patch.object(agent, "get_profile", return_value={"name": "Jeff"}): - sys = agent._build_system("+1555") - assert "full stop" not in sys - - def test_stale_context_is_filtered_before_the_model_sees_it(self): - life = userprofile.VOLATILE_FIELDS["life_context"] - profile = {"city": "Culver City", "timezone": "America/Los_Angeles", - "life_context": "Based in LA.", - "field_dates": {"life_context": (local_today("America/Los_Angeles") - - timedelta(days=life + 1)).isoformat()}} - with patch.object(agent, "get_profile", return_value=profile): - sys = agent._build_system("+1555") - assert "Based in LA" not in sys - - -class TestEmptySectionsDoNotLockForTheFullWindow: - """The `_tried` stamp is written before the call so a failure cannot loop. - That also meant one empty fetch left a section blank for its whole window — - it locked three of four users out of Opening for a day, twice.""" - - def test_a_section_holding_data_waits_the_full_window(self): - assert home._window_for("opening", True) == home.STALE["opening"] - - def test_an_empty_section_retries_sooner(self): - assert home._window_for("opening", False) < home.STALE["opening"] - assert home._window_for("headlines", False) < home.STALE["headlines"] - - def test_the_retry_is_bounded_not_a_loop(self): - for section in ("opening", "headlines"): - assert home._window_for(section, False) >= home.EMPTY_RETRY_FLOOR - - def test_a_never_on_view_section_stays_that_way(self): - with patch.dict(home.STALE, {"opening": None}): - assert home._window_for("opening", False) is None - - def test_the_gates_read_whether_data_exists(self): - import inspect - src = inspect.getsource(home.refresh_stale) - assert 'payload.get("opening")' in src and 'payload.get("headlines")' in src - - -class TestTheCardUsesTheReadersClock: - """page.py has always rendered the user's local day; cards.py defaulted to - datetime.now(), which is UTC on the dyno. From 5pm Pacific the card printed - tomorrow's date beside a page printing today's.""" - - def test_the_masthead_follows_the_profile_timezone(self): - la = artifacts._card_now({"timezone": "America/Los_Angeles"}) - chi = artifacts._card_now({"timezone": "America/Chicago"}) - assert la.utcoffset() != chi.utcoffset() - - def test_a_missing_timezone_asserts_no_day_at_all(self): - """It used to fall back to a naive datetime.now() — UTC on the dyno — - and print that unlabelled as the reader's day, which is the exact - thing clock_block was rewritten to stop doing. page.py omits the date - in this case, so the card has to as well or the two disagree.""" - assert artifacts._card_now({}) is None - assert artifacts._card_now({"timezone": "Not/AZone"}) is None - - def test_the_card_still_renders_without_a_day(self): - from palmer import cards - png = cards.render_dashboard(city="Austin", weather=None, traffic=None, - prices=None, headlines=None, show_date=False) - assert png[:4] == b"\x89PNG" - - def test_the_page_omits_it_too(self): - from palmer import page - assert page._local_day(None) == "" - assert page._local_day("Not/AZone") == "" - assert page._local_day("America/Chicago") != "" - - def test_the_renderer_is_told_the_time(self): - import inspect - src = inspect.getsource(artifacts.render_png) - assert "_card_now(payload)" in src - assert "show_date=" in src - - def test_the_cache_key_uses_the_readers_day(self): - """Otherwise the cached card outlives the reader's midnight.""" - import inspect - assert "_card_now(payload)" in inspect.getsource(artifacts._card_inputs) - - -class TestTheExtractorStopsRecordingItself: - def test_the_prompt_forbids_meta_facts(self): - """follow_up held "confirm_morning_briefing_delivery_is_consistent_daily" - for one user and "Maintain single-message format" for another — notes - about the product, read back as facts about a person.""" - from palmer import prompts - assert "never about Palmer's own operation" in prompts.EXTRACT_PROMPT - assert "return nothing for these fields" in prompts.EXTRACT_PROMPT diff --git a/tests/test_profile_schema.py b/tests/test_profile_schema.py deleted file mode 100644 index bed8737..0000000 --- a/tests/test_profile_schema.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Tests for the profile allow-list and the pruning migration. - -The extractor is a language model writing straight into a dict that gets dumped -as JSON into every system prompt. Unbounded, one profile reached 624 keys — 604 -invented one-offs, ~21,700 tokens of noise per message. These pin the bound. -""" -from unittest.mock import patch, MagicMock - -from palmer import db -from palmer import userprofile -from scripts import migrate_profile_prune as mig - - -class TestAllowList: - def test_known_fields_survive(self): - out = userprofile._canonical_updates({"city": "Kirkwood", "interests": ["golf"]}) - assert out == {"city": "Kirkwood", "interests": ["golf"]} - - def test_invented_fields_are_dropped(self): - out = userprofile._canonical_updates({ - "city": "Kirkwood", "monday_night_behavior": "watches film", - "kendrick_fan": True, "alternatively": "x", - }) - assert out == {"city": "Kirkwood"} - - def test_aliases_still_map_and_retire(self): - out = userprofile._canonical_updates({"location": "Kirkwood"}) - assert out["city"] == "Kirkwood" - assert out["location"] is None, "the alias must be cleared, not left behind" - - def test_every_field_the_code_reads_is_allowed(self): - """A key missing from PROFILE_FIELDS would be silently discarded on write.""" - for field in ("city", "timezone", "morning_topics", "morning_time", - "morning_onboarded", "morning_prefs", "morning_sent_date", - "commute", "communication_style", "reactions", - "reactions_folded_count", "conversation_topics", - "pending_morning_suggestion", "pending_preference_notice", - "followup_sent_date", "intro_sent", - "ongoing_threads", "life_context", - "onboarding_ask_sent", "score_offer_sent", "followed_teams"): - assert field in userprofile.PROFILE_FIELDS, field - - -class TestPrune: - def test_splits_kept_from_dropped(self): - kept, dropped = userprofile.prune_profile({"city": "K", "kendrick_fan": True}) - assert kept == {"city": "K"} and dropped == ["kendrick_fan"] - - def test_pure(self): - profile = {"city": "K", "junk": 1} - userprofile.prune_profile(profile) - assert "junk" in profile, "prune must not mutate its input" - - def test_empty(self): - assert userprofile.prune_profile({}) == ({}, []) - - -class TestNoneDeletes: - """A stored null still costs prompt tokens. None must remove the key.""" - - def test_none_removes_the_key(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t.db", raising=False) - with patch.object(db, "_conn", db._conn): - db.init_db() - db.upsert_profile("+1555", {"city": "Kirkwood", "vibe": "dry"}) - db.upsert_profile("+1555", {"vibe": None}) - prof = db.get_profile("+1555") - assert prof["city"] == "Kirkwood" - assert "vibe" not in prof, "None should delete, not store a null" - - def test_deleting_an_absent_key_is_a_noop(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t2.db", raising=False) - db.init_db() - db.upsert_profile("+1556", {"city": "K"}) - db.upsert_profile("+1556", {"never_set": None}) - assert db.get_profile("+1556")["city"] == "K" - - -class TestTopicCleanup: - ROUTE = ("Daily commute traffic: 33 Cedarbrook Lane, Kirkwood MO 63122 " - "to 190 Carondelet Plaza, Clayton MO 63105") - FORMAT = "Format: bullet points per subject, not one continuous paragraph" - - def test_directive_and_route_leave_the_topic_list(self): - out = mig._clean_topics({"morning_topics": ["SpaceX news", self.FORMAT, self.ROUTE]}) - assert out["morning_topics"] == ["SpaceX news"] - - def test_route_is_promoted_not_discarded(self): - out = mig._clean_topics({"morning_topics": [self.ROUTE]}) - assert out["commute"] == { - "origin": "33 Cedarbrook Lane, Kirkwood MO 63122", - "destination": "190 Carondelet Plaza, Clayton MO 63105", - } - - def test_existing_commute_is_not_overwritten(self): - existing = {"origin": "A Street, Town", "destination": "B Street, City"} - out = mig._clean_topics({"commute": existing, "morning_topics": [self.ROUTE]}) - assert "commute" not in out - assert out["morning_topics"] == [] - - def test_clean_list_is_left_alone(self): - assert mig._clean_topics({"morning_topics": ["SpaceX news", "LA weather"]}) == {} - - def test_no_topics(self): - assert mig._clean_topics({}) == {} - - -class TestConsolidationSafety: - def test_only_merge_targets_are_accepted(self): - """The fold must never move a briefing time or clear a send guard.""" - from unittest.mock import MagicMock - b = MagicMock(); b.text = '{"interests": ["golf"], "morning_time": "03:00", "city": "Nowhere"}' - r = MagicMock(); r.content = [b] - with patch.object(mig.client.messages, "create", return_value=r): - out = mig._consolidate({}, {"junk": 1}) - assert out == {"interests": ["golf"]} - assert "morning_time" not in out and "city" not in out - - def test_failure_prunes_without_merging(self): - with patch.object(mig.client.messages, "create", side_effect=RuntimeError("boom")): - assert mig._consolidate({}, {"junk": 1}) == {} - - def test_nothing_stray_means_no_api_call(self): - with patch.object(mig.client.messages, "create") as create: - assert mig._consolidate({"city": "K"}, {}) == {} - create.assert_not_called() - - -class TestNameIsExtracted: - """"My name is Jeff" returned {} from the extractor, so profile["name"] - stayed empty while Palmer happily called the user Jeff from conversation - history. The page reads the profile, so it showed "Your briefing" and kept - asking for a name it had already been told twice.""" - - def test_the_schema_demands_identity_explicitly(self): - from palmer.prompts import EXTRACT_PROMPT - assert "IDENTITY FIRST" in EXTRACT_PROMPT - - def test_it_names_the_phrasings_people_actually_use(self): - from palmer.prompts import EXTRACT_PROMPT - low = EXTRACT_PROMPT.lower() - for phrase in ("my name is", "i'm jeff", "call me"): - assert phrase in low - - def test_it_overrides_the_skip_when_already_present_instinct(self): - """The failure was the model deciding the name was too obvious to - bother returning, or assuming it must already be stored.""" - from palmer.prompts import EXTRACT_PROMPT - low = EXTRACT_PROMPT.lower() - assert "even when" in low and "already" in low - - def test_name_is_still_an_allowed_field(self): - from palmer.userprofile import PROFILE_FIELDS - assert "name" in PROFILE_FIELDS - - -class TestCityPrecisionIsExtracted: - """A generic city mention ("LA traffic today") was overwriting a specific, - correct one ("Culver City, CA") already on file, because EXTRACT_PROMPT had - no rule distinguishing "where they live" from "a place they mentioned".""" - - def test_the_schema_has_a_location_precision_rule(self): - from palmer.prompts import EXTRACT_PROMPT - assert "LOCATION PRECISION" in EXTRACT_PROMPT - - def test_it_excludes_passing_mentions(self): - from palmer.prompts import EXTRACT_PROMPT - low = EXTRACT_PROMPT.lower() - for phrase in ("in passing", "traffic into la", "leave the existing value alone"): - assert phrase in low - - def test_consolidate_prompt_also_guards_city(self): - from palmer.prompts import CONSOLIDATE_PROMPT - low = CONSOLIDATE_PROMPT.lower() - assert '"city"' in low and "leave it unchanged" in low - - -class TestCityChangeIsLogged: - def test_city_regression_prints_old_and_new(self, tmp_path, monkeypatch, capsys): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t3.db", raising=False) - monkeypatch.setenv("APP_URL", "https://palmer.test") - db.init_db() - db.upsert_profile("+1557", {"city": "Culver City, CA"}) - profile = db.get_profile("+1557") - with patch("palmer.home.rebuild") as rebuild: - userprofile._apply_profile_updates("+1557", profile, {"city": "Los Angeles"}) - out = capsys.readouterr().out - assert "Culver City, CA" in out and "Los Angeles" in out - rebuild.assert_not_called(), "a correction to an existing city is not a day-1 build" - - -class TestEagerHomeBuild: - """The first time a user's city becomes known, Palmer Home gets built right - away rather than waiting for get_my_page or the morning job — see CLAUDE.md - "Onboarding" / the day-1 site build. It never sends the link; that's still - gated on an explicit ask or the first morning send, unchanged.""" - - def test_first_city_triggers_a_build(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t6.db", raising=False) - monkeypatch.setenv("APP_URL", "https://palmer.test") - db.init_db() - db.upsert_profile("+1558", {}) - profile = db.get_profile("+1558") - with patch("palmer.home.home_token", return_value="tok"), \ - patch("palmer.home.load", return_value=None), \ - patch("palmer.home.rebuild") as rebuild: - userprofile._apply_profile_updates("+1558", profile, {"city": "Chicago"}) - rebuild.assert_called_once_with("+1558", refresh_news=True) - - def test_city_correction_does_not_rebuild(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t7.db", raising=False) - monkeypatch.setenv("APP_URL", "https://palmer.test") - db.init_db() - db.upsert_profile("+1559", {"city": "Culver City, CA"}) - profile = db.get_profile("+1559") - with patch("palmer.home.rebuild") as rebuild: - userprofile._apply_profile_updates("+1559", profile, {"city": "Los Angeles"}) - rebuild.assert_not_called() - - def test_no_app_url_skips_the_build(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t8.db", raising=False) - monkeypatch.delenv("APP_URL", raising=False) - db.init_db() - db.upsert_profile("+1560", {}) - profile = db.get_profile("+1560") - with patch("palmer.home.rebuild") as rebuild: - userprofile._apply_profile_updates("+1560", profile, {"city": "Chicago"}) - rebuild.assert_not_called() - - def test_a_page_already_built_is_not_rebuilt_again(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t9.db", raising=False) - monkeypatch.setenv("APP_URL", "https://palmer.test") - db.init_db() - db.upsert_profile("+1561", {}) - profile = db.get_profile("+1561") - with patch("palmer.home.home_token", return_value="tok"), \ - patch("palmer.home.load", return_value={"city": "Chicago"}), \ - patch("palmer.home.rebuild") as rebuild: - userprofile._apply_profile_updates("+1561", profile, {"city": "Chicago"}) - rebuild.assert_not_called() - - -class TestOnboardingAskConsumption: - """The ONBOARDING ASK block in _build_system (agent.py) fires under this - exact condition; _update_profile marks it consumed the first time it sees - that same condition hold true after a turn's extraction runs.""" - - def test_marks_consumed_after_a_qualifying_turn(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t10.db", raising=False) - db.init_db() - db.upsert_profile("+1562", {"intro_sent": True}) - block = MagicMock(); block.text = "{}" - resp = MagicMock(); resp.content = [block] - with patch.object(userprofile.client.messages, "create", return_value=resp): - userprofile._update_profile("+1562", "hey", "hey, how's it going") - assert db.get_profile("+1562")["onboarding_ask_sent"] is True - - def test_not_marked_when_the_turn_supplies_both_fields(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t11.db", raising=False) - monkeypatch.delenv("APP_URL", raising=False) - db.init_db() - db.upsert_profile("+1563", {"intro_sent": True}) - block = MagicMock(); block.text = '{"name": "Ada", "city": "Chicago"}' - resp = MagicMock(); resp.content = [block] - with patch.object(userprofile.client.messages, "create", return_value=resp): - userprofile._update_profile("+1563", "I'm Ada from Chicago", "hey Ada") - assert "onboarding_ask_sent" not in db.get_profile("+1563") - - def test_not_marked_before_intro_is_sent(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "t12.db", raising=False) - db.init_db() - db.upsert_profile("+1564", {}) - block = MagicMock(); block.text = "{}" - resp = MagicMock(); resp.content = [block] - with patch.object(userprofile.client.messages, "create", return_value=resp): - userprofile._update_profile("+1564", "hey", "hey") - assert "onboarding_ask_sent" not in db.get_profile("+1564") - - -class TestNoFieldIsAlsoAnAlias: - """`teams` shipped as a real PROFILE_FIELD while `_PROFILE_ALIASES` still - mapped it to `sports_teams`. `_normalize_profile` runs on every inbound - message, so `follow_team` stored a follow list, Palmer confirmed it, and the - next message migrated it into `sports_teams` and wrote `teams: None` — - the follow silently gone, its games out of the morning, and the interest collector then - raising AttributeError on a dict from a call site outside a try. - """ - - def test_an_alias_key_is_never_a_real_field(self): - from palmer import userprofile as up - clashes = {k: v for k, v in up._PROFILE_ALIASES.items() if k in up.PROFILE_FIELDS} - assert not clashes, f"these fields are silently rewritten on write: {clashes}" - - def test_the_structured_follow_lists_survive_a_normalise(self): - from palmer import userprofile as up - for field in ("followed_teams", "shows"): - row = [{"league": "nfl", "abbrev": "PHI", "name": "Philadelphia Eagles"}] - out = up._canonical_updates({field: row}) - assert out.get(field) == row, f"{field} did not survive canonicalisation" - - def test_the_tool_written_lists_are_not_in_the_extractor_schema(self): - """They hold structured dicts written by tool dispatch. In the schema, - Haiku fills them with prose and downstream code gets strings.""" - from palmer import prompts - for field in ("followed_teams", "shows", "weather_locations"): - assert f'"{field}"' not in prompts.EXTRACT_PROMPT diff --git a/tests/test_reaction_interpret.py b/tests/test_reaction_interpret.py deleted file mode 100644 index dfd4bd4..0000000 --- a/tests/test_reaction_interpret.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Tests for reaction interpretation and the reply/silence decision. - -Haiku is mocked throughout — these test the plumbing, the validation, and above -all the failure behavior. The rule that matters: anything that goes wrong must -land on silence, never on an unwanted text. - -Live model behavior (does Haiku actually call a thumbs-up on a question an -"answer"?) is verified separately against the real API, not here. -""" -from unittest.mock import patch, MagicMock - -import pytest - -from palmer import tapback - -with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - from palmer import main - - -def _haiku(text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - -LIKED = {"kind": "liked", "sentiment": "positive", "quoted": "want me to add that?", "emoji": ""} -THUMBS = {"kind": "emoji", "sentiment": "positive", "quoted": "", "emoji": "\U0001f44d"} - - -class TestInterpretVerdicts: - @pytest.mark.parametrize("function,needs_reply", [ - ("answer", True), - ("closer", False), - ("applause", False), - ("objection", False), - ("emotional", False), - ]) - def test_only_answer_needs_reply(self, function, needs_reply): - payload = f'{{"function": "{function}", "sentiment": "positive", "about": "mornings"}}' - with patch.object(tapback.client.messages, "create", return_value=_haiku(payload)): - v = tapback.interpret_reaction(THUMBS, "want me to add that to your morning?", {}) - assert v["function"] == function - assert v["needs_reply"] is needs_reply - - def test_sentiment_is_taken_from_the_model_not_the_emoji_map(self): - """A skull is 'neutral' to the static map; in context it can be praise.""" - skull = {"kind": "emoji", "sentiment": "neutral", "quoted": "", "emoji": "\U0001f480"} - payload = '{"function": "applause", "sentiment": "positive", "about": "the joke"}' - with patch.object(tapback.client.messages, "create", return_value=_haiku(payload)): - v = tapback.interpret_reaction(skull, "They always pick the day before the weekend.", {}) - assert v["sentiment"] == "positive" - - def test_about_is_captured_and_truncated(self): - payload = '{"function": "objection", "sentiment": "negative", "about": "' + "x" * 200 + '"}' - with patch.object(tapback.client.messages, "create", return_value=_haiku(payload)): - v = tapback.interpret_reaction(LIKED, "bitcoin is up 12%", {}) - assert len(v["about"]) <= 40 - - -class TestInterpretFailsSafe: - """Every failure path must produce silence.""" - - def test_api_exception(self): - with patch.object(tapback.client.messages, "create", side_effect=RuntimeError("boom")): - v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) - assert v["needs_reply"] is False and v["function"] == "closer" - - def test_unparseable_response(self): - with patch.object(tapback.client.messages, "create", return_value=_haiku("not json at all")): - v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) - assert v["needs_reply"] is False - - def test_invalid_function_value(self): - with patch.object(tapback.client.messages, "create", - return_value=_haiku('{"function": "vibes", "sentiment": "positive"}')): - v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) - assert v["function"] == "closer" and v["needs_reply"] is False - - def test_invalid_sentiment_falls_back_to_parsed(self): - with patch.object(tapback.client.messages, "create", - return_value=_haiku('{"function": "closer", "sentiment": "spicy"}')): - v = tapback.interpret_reaction(THUMBS, "hi", {}) - assert v["sentiment"] in ("positive", "negative", "neutral") - - def test_no_last_assistant_skips_the_model_entirely(self): - """Nothing to answer means no question was asked — don't pay for a call.""" - with patch.object(tapback.client.messages, "create") as create: - v = tapback.interpret_reaction(THUMBS, "", {}) - create.assert_not_called() - assert v["needs_reply"] is False - - def test_empty_reaction(self): - assert tapback.interpret_reaction({}, "hi", {})["needs_reply"] is False - - -class TestConsolidation: - def _profile(self, n): - return {"reactions": [ - {"kind": "liked", "sentiment": "positive", "quoted": f"m{i}", "about": ""} - for i in range(n) - ]} - - def test_does_not_fire_below_threshold(self): - with patch.object(tapback, "get_profile", - return_value=self._profile(tapback.CONSOLIDATE_EVERY - 1)), \ - patch.object(tapback.client.messages, "create") as create, \ - patch.object(tapback, "upsert_profile") as upsert: - tapback.maybe_consolidate("+15550001111") - create.assert_not_called() - upsert.assert_not_called() - - def test_fires_at_threshold_and_updates_style(self): - stored = {} - with patch.object(tapback, "get_profile", return_value=self._profile(tapback.CONSOLIDATE_EVERY)), \ - patch.object(tapback.client.messages, "create", - return_value=_haiku('{"communication_style": "likes the dry stuff"}')), \ - patch.object(tapback, "upsert_profile", side_effect=lambda p, u: stored.update(u)): - tapback.maybe_consolidate("+15550001111") - assert stored["communication_style"] == "likes the dry stuff" - assert stored["reactions_folded_count"] == tapback.CONSOLIDATE_EVERY - - def test_does_not_refire_until_another_batch(self): - prof = self._profile(tapback.CONSOLIDATE_EVERY) - prof["reactions_folded_count"] = tapback.CONSOLIDATE_EVERY - with patch.object(tapback, "get_profile", return_value=prof), \ - patch.object(tapback.client.messages, "create") as create: - tapback.maybe_consolidate("+15550001111") - create.assert_not_called() - - def test_failure_is_swallowed(self): - with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): - tapback.maybe_consolidate("+15550001111") - - -class TestHandlerRouting: - """End-to-end through _handle_sms with the verdict controlled.""" - - def _run(self, body, verdict, last_assistant="want me to add that to your morning?"): - history = [{"role": "assistant", "content": last_assistant}] if last_assistant else [] - with patch.object(main, "ensure_sms", return_value=True) as ensure, \ - patch.object(main, "get_reply", return_value=("done, added", None)) as get_reply, \ - patch.object(main, "interpret_reaction", return_value=verdict), \ - patch.object(main, "record_reaction") as record, \ - patch.object(main, "learn_from_reactions") as consolidate, \ - patch.object(main, "save_message"), \ - patch.object(main, "save_assistant_turn"), \ - patch.object(main, "get_history", return_value=history), \ - patch.object(main, "get_profile", return_value={"intro_sent": True}), \ - patch.object(main, "upsert_profile"): - main._handle_sms("+15550001111", body, None) - return ensure, get_reply, record, consolidate - - def test_closer_stays_silent(self): - ensure, get_reply, record, consolidate = self._run( - 'Liked "the audacity of it"', - {"function": "closer", "sentiment": "positive", "about": "", "needs_reply": False}, - ) - ensure.assert_not_called() - get_reply.assert_not_called() - record.assert_called_once() - consolidate.assert_called_once() - - def test_answer_gets_exactly_one_reply(self): - ensure, get_reply, record, _ = self._run( - "\U0001f44d", - {"function": "answer", "sentiment": "positive", "about": "mornings", "needs_reply": True}, - ) - get_reply.assert_called_once() - ensure.assert_called_once() - record.assert_called_once() - - def test_answer_is_relabelled_for_the_model(self): - """Palmer must see 'this is their answer', not a bare emoji.""" - with patch.object(main, "ensure_sms", return_value=True), \ - patch.object(main, "get_reply", return_value=("done", None)) as get_reply, \ - patch.object(main, "interpret_reaction", - return_value={"function": "answer", "sentiment": "positive", - "about": "", "needs_reply": True}), \ - patch.object(main, "record_reaction"), \ - patch.object(main, "learn_from_reactions"), \ - patch.object(main, "save_message"), \ - patch.object(main, "save_assistant_turn"), \ - patch.object(main, "get_history", - return_value=[{"role": "assistant", "content": "want me to add that?"}]), \ - patch.object(main, "get_profile", return_value={"intro_sent": True}), \ - patch.object(main, "upsert_profile"): - main._handle_sms("+15550001111", "\U0001f44d", None) - sent = get_reply.call_args.kwargs["message"] - assert "this is their answer" in sent - assert "want me to add that?" in sent - - def test_objection_stays_silent(self): - ensure, get_reply, _, _ = self._run( - 'Disliked "bitcoin is up 12%"', - {"function": "objection", "sentiment": "negative", "about": "bitcoin", "needs_reply": False}, - ) - ensure.assert_not_called() - get_reply.assert_not_called() - - -def _neg(topic, n): - return [{"kind": "disliked", "sentiment": "negative", "function": "objection", - "about": topic, "quoted": ""} for _ in range(n)] - - -class TestPacingFactor: - def test_normal_with_no_reactions(self): - assert tapback.pacing_factor({}) == 1.0 - assert tapback.pacing_factor({"reactions": []}) == 1.0 - - def test_positives_do_not_slow_palmer_down(self): - prof = {"reactions": [{"kind": "liked", "sentiment": "positive", "function": "applause"}] * 6} - assert tapback.pacing_factor(prof) == 1.0 - - def test_negatives_increase_the_factor(self): - assert tapback.pacing_factor({"reactions": _neg("crypto", 2)}) > 1.0 - - def test_capped(self): - assert tapback.pacing_factor({"reactions": _neg("crypto", 50)}) == tapback.MAX_PACING_FACTOR - - def test_decays_as_positives_push_negatives_out(self): - """The rolling log is the decay mechanism — no reset job needed.""" - heavy = tapback.pacing_factor({"reactions": _neg("crypto", 4)}) - recovered = tapback.pacing_factor({"reactions": _neg("crypto", 1)}) - assert recovered < heavy - - def test_followup_gap_stretches(self): - from palmer import followup - from datetime import datetime, timedelta - base = {"morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": ["job offer"]} - short = followup.GAP_DAYS - 1 - recent = (datetime.now() - timedelta(days=short)).date().isoformat() - with patch.object(followup, "_local_now", - return_value=datetime.now().replace(hour=15)): - calm = dict(base, followup_sent_date=recent, reactions=[]) - noisy = dict(base, followup_sent_date=recent, reactions=_neg("checkins", 4)) - assert followup._should_send_followup(calm) is False - assert followup._should_send_followup(noisy) is False - - due = (datetime.now() - timedelta(days=followup.GAP_DAYS + 1)).date().isoformat() - with patch.object(followup, "_local_now", - return_value=datetime.now().replace(hour=15)): - calm = dict(base, followup_sent_date=due, reactions=[]) - noisy = dict(base, followup_sent_date=due, reactions=_neg("checkins", 4)) - assert followup._should_send_followup(calm) is True, "normal user is due" - assert followup._should_send_followup(noisy) is False, "backed-off user is not" - - -class TestPreferenceLearning: - def _run(self, log, existing_prefs=None, topics=("crypto", "sports", "weather")): - stored = {} - prof = {"reactions": log, "morning_topics": list(topics)} - if existing_prefs is not None: - prof["morning_prefs"] = existing_prefs - with patch.object(tapback, "get_profile", return_value=prof), \ - patch.object(tapback, "upsert_profile", side_effect=lambda p, u: stored.update(u)): - tapback.maybe_learn_preferences("+15550001111") - return stored - - def test_one_dislike_changes_nothing(self): - assert self._run(_neg("crypto", 1)) == {} - - def test_two_dislikes_change_nothing(self): - assert self._run(_neg("crypto", 2)) == {} - - def test_threshold_adds_to_avoid(self): - stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID)) - assert "crypto" in stored["morning_prefs"]["avoid"] - - def test_sets_a_notice_so_it_is_not_silent(self): - stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID)) - assert stored["pending_preference_notice"] == "crypto" - - def test_preserves_existing_avoid_entries(self): - stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), - existing_prefs={"avoid": ["sports"]}) - assert set(stored["morning_prefs"]["avoid"]) == {"sports", "crypto"} - - def test_does_not_duplicate(self): - assert self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), - existing_prefs={"avoid": ["crypto"]}) == {} - - def test_spread_across_topics_does_not_trigger(self): - log = _neg("crypto", 1) + _neg("sports", 1) + _neg("weather", 1) - assert self._run(log) == {} - - def test_blank_topic_ignored(self): - assert self._run(_neg("", 5)) == {} - - def test_topic_not_in_the_briefing_is_ignored(self): - """Only things Palmer actually sends can be dropped from what he sends.""" - assert self._run(_neg("someone's dog", tapback.NEGATIVE_STREAK_FOR_AVOID)) == {} - - def test_no_briefing_topics_means_nothing_to_drop(self): - assert self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), topics=()) == {} - - def test_matching_is_case_insensitive(self): - stored = self._run(_neg("CRYPTO", tapback.NEGATIVE_STREAK_FOR_AVOID)) - assert "crypto" in [a.lower() for a in stored["morning_prefs"]["avoid"]] - - def test_stores_the_original_topic_casing(self): - """morning.py matches against morning_topics, so casing must round-trip.""" - stored = self._run(_neg("bitcoin", tapback.NEGATIVE_STREAK_FOR_AVOID), - topics=("Bitcoin",)) - assert "Bitcoin" in stored["morning_prefs"]["avoid"] - - def test_failure_is_swallowed(self): - with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): - tapback.maybe_learn_preferences("+15550001111") - - -class TestNoticeSurfacedAndCleared: - def test_notice_appears_in_prompt(self): - from palmer import agent - with patch.object(agent, "get_profile", - return_value={"pending_preference_notice": "crypto"}), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - out = agent._build_system("+15550001111") - assert "crypto" in out - assert "thumbs-downed" in out - - def test_notice_absent_when_unset(self): - from palmer import agent - with patch.object(agent, "get_profile", return_value={"name": "Mike"}), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - out = agent._build_system("+15550001111") - assert "thumbs-downed" not in out - - def test_notice_is_cleared_after_being_shown(self): - """Otherwise Palmer mentions the dropped topic every single turn.""" - from palmer import agent - stored = {} - with patch.object(agent, "_update_profile"), \ - patch.object(agent, "_consolidate_history"), \ - patch.object(agent, "upsert_profile", side_effect=lambda p, u: stored.update(u)), \ - patch.object(agent, "get_profile", return_value={}): - agent._profile_and_consolidate("+15550001111", "ok", "sure", None, "crypto") - assert stored["pending_preference_notice"] is None - - -class TestWatchCapLookupIsPerUser: - """run_watches covers every watch for every user and get_profile opens a - fresh DB connection per call — the cap lookup must not be inside the loop.""" - - def test_profile_read_once_per_user_not_per_watch(self): - from palmer import watches - rows = [{"phone": "+1555000111" + str(i % 2), "id": i, "last_alerted": None, - "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, - "queries": "[]", "description": "x", "genre": None, "story_state": None} - for i in range(6)] # 6 watches spread across 2 users - - with patch.object(watches, "get_active_watches", return_value=rows), \ - patch.object(watches, "get_profile", return_value={}) as gp, \ - patch.object(watches, "_search_raw", return_value=[]): - watches.run_watches() - - assert gp.call_count == 2, f"expected 1 profile read per user, got {gp.call_count}" - - def test_profile_failure_falls_back_to_default_cap(self): - from palmer import watches - rows = [{"phone": "+15550001111", "id": 1, "last_alerted": None, - "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, - "queries": "[]", "description": "x", "genre": None, "story_state": None}] - with patch.object(watches, "get_active_watches", return_value=rows), \ - patch.object(watches, "get_profile", side_effect=RuntimeError("db down")), \ - patch.object(watches, "_search_raw", return_value=[]): - watches.run_watches() # must not raise diff --git a/tests/test_reminder_recurrence.py b/tests/test_reminder_recurrence.py deleted file mode 100644 index 390c305..0000000 --- a/tests/test_reminder_recurrence.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Recurring reminders, and the dedup guard that was wrong in both directions. - -Production evidence that motivated all of this — one user asked for a *daily* -update and got four texts in the same minute, then nothing, ever: - - 133 | Daily Eagles camp update for the user | 2026-08-11T20:00:00Z - 134 | Eagles camp update - how the day went | 2026-08-11T20:00:00Z - 135 | Eagles camp update - how did today's practice go? | 2026-08-11T20:00:00Z - 136 | Eagles camp update - how the day went | 2026-08-11T20:00:00Z - -Two independent defects: reminders had no recurrence, and the duplicate guard -matched exact text while ignoring due_at entirely. -""" -from dotenv import load_dotenv -load_dotenv() - -from datetime import datetime, timedelta, timezone -from unittest.mock import patch -from zoneinfo import ZoneInfo - -from palmer import db -from palmer.timeutil import next_occurrence - -CHI = ZoneInfo("America/Chicago") -PHONE = "+15551234567" - - -def _fresh_db(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_recurrence.db") - db.init_db() - - -class TestNextOccurrence: - def test_preserves_local_wall_clock_across_dst_end(self): - """The reminder is "3pm", not "20:00Z". Chicago leaves CDT on 2026-11-01, - so holding the UTC instant fixed would walk a 3pm reminder to 2pm and - leave it there. The UTC instant MUST move for the local time to stay put.""" - due = datetime(2026, 10, 31, 15, 0, tzinfo=CHI) - nxt = next_occurrence(due, "daily", "America/Chicago", now=due) - assert nxt.astimezone(CHI).hour == 15 - assert due.astimezone(timezone.utc).hour == 20 # CDT, UTC-5 - assert nxt.astimezone(timezone.utc).hour == 21 # CST, UTC-6 — it moved - - def test_skips_missed_periods_instead_of_bursting(self): - """due_at <= now has catch-up semantics, so advancing by exactly one - period after an outage would fire once per missed day on recovery.""" - due = datetime(2026, 8, 10, 15, 0, tzinfo=CHI) - now = datetime(2026, 8, 13, 18, 0, tzinfo=CHI) # three days stale - nxt = next_occurrence(due, "daily", "America/Chicago", now=now) - assert nxt.astimezone(CHI).date() == datetime(2026, 8, 14).date() - assert nxt > now - - def test_weekdays_rolls_friday_to_monday(self): - friday = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) - assert friday.weekday() == 4 - nxt = next_occurrence(friday, "weekdays", "America/Chicago", now=friday) - assert nxt.astimezone(CHI).weekday() == 0 - assert nxt.astimezone(CHI).hour == 9 - - def test_weekly_keeps_the_weekday(self): - friday = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) - nxt = next_occurrence(friday, "weekly", "America/Chicago", now=friday) - assert nxt.astimezone(CHI).weekday() == 4 - assert (nxt.astimezone(CHI).date() - friday.date()).days == 7 - - def test_unknown_recurrence_is_none(self): - # Guards the send path: an unrecognised value must not re-arm anything. - due = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) - for bad in ("hourly", "monthly", "", None, "DAILY-ISH"): - assert next_occurrence(due, bad, "America/Chicago", now=due) is None - - def test_recurrence_is_case_and_space_tolerant(self): - due = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) - assert next_occurrence(due, " Daily ", "America/Chicago", now=due) is not None - - def test_missing_timezone_falls_back_to_utc(self): - due = datetime(2026, 8, 14, 9, 0, tzinfo=timezone.utc) - nxt = next_occurrence(due, "daily", None, now=due) - assert nxt.hour == 9 - - -class TestDedupGuard: - """Same time AND similar text. Either condition alone gets it wrong.""" - - EAGLES = [ - "Daily Eagles camp update for the user", - "Eagles camp update — how the day went", - "Eagles camp update - how did today's practice go?", - "Eagles camp update - how the day went", - ] - - def test_the_four_eagles_rows_collapse_to_one(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - for text in self.EAGLES: - db.save_reminder(PHONE, text, "2026-08-11T20:00:00Z") - rows = _pending(PHONE) - assert len(rows) == 1, [r["text"] for r in rows] - - def test_em_dash_vs_hyphen_is_not_a_new_reminder(self, tmp_path, monkeypatch): - # Rows 134 and 136 differed by exactly this and both got stored. - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Eagles camp update — how the day went", "2026-08-11T20:00:00Z") - db.save_reminder(PHONE, "Eagles camp update - how the day went", "2026-08-11T20:00:00Z") - assert len(_pending(PHONE)) == 1 - - def test_same_text_at_a_different_time_is_kept(self, tmp_path, monkeypatch): - """The old guard ignored due_at, so this silently dropped the second one.""" - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Call mom", "2026-08-11T20:00:00Z") - db.save_reminder(PHONE, "Call mom", "2026-08-18T20:00:00Z") - assert len(_pending(PHONE)) == 2 - - def test_different_errands_at_the_same_time_both_survive(self, tmp_path, monkeypatch): - # Time alone would have merged these. - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Call your mom", "2026-08-11T20:00:00Z") - db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z") - assert len(_pending(PHONE)) == 2 - - def test_offset_and_z_forms_compare_equal(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Move the car", "2026-08-11T20:00:00Z") - db.save_reminder(PHONE, "Move the car", "2026-08-11T20:00:00+00:00") - assert len(_pending(PHONE)) == 1 - - -class TestCancelAndRearm: - def test_cancel_clears_recurrence_so_rearm_cannot_resurrect(self, tmp_path, monkeypatch): - """Claiming and cancelling both set sent = 1, so they're indistinguishable - by `sent` alone. cancel_reminders also nulls recurrence, which is what - makes a cancel landing between claim and re-arm stick.""" - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") - rid = _pending(PHONE)[0]["id"] - - db.cancel_reminders(PHONE) - assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is False - assert _pending(PHONE) == [] - - def test_rearm_makes_a_recurring_reminder_pending_again(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") - rid = _pending(PHONE)[0]["id"] - db.claim_due_reminders() - assert _pending(PHONE) == [] - assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is True - assert len(_pending(PHONE)) == 1 - - def test_one_shot_reminders_are_never_rearmed(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Confirm lunch", "2026-08-11T20:00:00Z") - rid = _pending(PHONE)[0]["id"] - db.claim_due_reminders() - assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is False - - def test_claim_carries_recurrence_and_due_at(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") - claimed = db.claim_due_reminders() - assert claimed[0]["recurrence"] == "daily" - # save_reminder canonicalizes on write: one shape in the column, because - # claim_due_reminders orders it lexicographically. - assert claimed[0]["due_at"] == "2026-08-11T20:00:00+00:00" - - -class TestSendLoop: - """End to end through send_due_reminders with Twilio and the drafter stubbed.""" - - def _run(self, profile=None): - from palmer import reminders - sent = [] - with patch("palmer.sms_util.send_sms", side_effect=lambda p, b, **k: sent.append((p, b)) or True), \ - patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ - patch("palmer.reminders.get_profile", return_value=profile or {"timezone": "America/Chicago"}), \ - patch("palmer.reminders.save_message"): - reminders.send_due_reminders() - return sent - - def test_recurring_reminder_sends_then_rearms_into_the_future(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() - db.save_reminder(PHONE, "Take your meds", past, "daily") - - sent = self._run() - assert len(sent) == 1 - - pending = _pending(PHONE) - assert len(pending) == 1, "recurring reminder should be pending again" - assert db._parse_due(pending[0]["due_at"]) > datetime.now(timezone.utc) - - def test_one_shot_reminder_does_not_come_back(self, tmp_path, monkeypatch): - _fresh_db(tmp_path, monkeypatch) - past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() - db.save_reminder(PHONE, "Confirm lunch with grandpa", past) - assert len(self._run()) == 1 - assert _pending(PHONE) == [] - - def test_near_identical_reminders_due_together_send_once(self, tmp_path, monkeypatch): - """The observed failure. These reach the send loop only if they were - written before the dedup guard existed, so the guard is scoped to the - tick rather than to _is_duplicate_subject — a reminder is explicitly - requested, and suppressing one for topical overlap hours later would be - worse than the duplicate it prevents.""" - _fresh_db(tmp_path, monkeypatch) - past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() - for text in TestDedupGuard.EAGLES: - _force_insert(PHONE, text, past) - assert len(_pending(PHONE)) == 4 - assert len(self._run()) == 1 - - def test_a_failed_send_still_rearms(self, tmp_path, monkeypatch): - """The claim already consumed this occurrence, so bailing on a Twilio - hiccup would silently end a standing reminder.""" - _fresh_db(tmp_path, monkeypatch) - past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() - db.save_reminder(PHONE, "Take your meds", past, "daily") - - from palmer import reminders - with patch("palmer.sms_util.send_sms", return_value=False), \ - patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ - patch("palmer.reminders.get_profile", return_value={"timezone": "America/Chicago"}), \ - patch("palmer.reminders.save_message"): - reminders.send_due_reminders() - - assert len(_pending(PHONE)) == 1 - - -# --- helpers ----------------------------------------------------------------- - -def _pending(phone): - conn = db._conn() - cur = conn.cursor() - cur.execute(f"SELECT id, text, due_at, recurrence FROM reminders WHERE phone = {db.PH} AND sent = 0", (phone,)) - rows = [dict(r) for r in cur.fetchall()] - conn.close() - return rows - - -def _force_insert(phone, text, due_at): - """Bypass save_reminder's guard, to recreate rows written before it existed.""" - conn = db._conn() - cur = conn.cursor() - cur.execute( - f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", - (phone, text, due_at), - ) - conn.commit() - conn.close() - - -class TestTheWritePathRefusesARecurrenceItCannotKeep: - """next_occurrence returning None guards the SEND path. Nothing guarded the - write path, and that is where the damage was done. - - A tool-use `enum` is guidance to the model, not a constraint the API - enforces. So "monthly" reached db.save_reminder intact, the dispatch - confirmed "It repeats (monthly) at that same local time until they cancel", - and then the send path quietly declined to re-arm it. The user was told it - repeats, got exactly one text, and had no way to know it had stopped — - which is the outcome SYSTEM_PROMPT and the set_reminder description both - name as the thing never to let happen. - """ - - def _set_reminder(self, recurrence): - """Run one set_reminder tool call and return (result string, saved rows).""" - from unittest.mock import MagicMock - from palmer import agent - - saved = [] - block = MagicMock() - block.type = "tool_use" - block.name = "set_reminder" - block.id = "tu_1" - # Comfortably future but inside _normalize_due_at's 400-day bound, so - # this exercises the recurrence check and not the date check. - due = (datetime.now(timezone.utc) + timedelta(days=30)).replace( - microsecond=0).isoformat() - block.input = {"text": "take the bins out", - "due_at": due, - "recurrence": recurrence} - - calls = [] - - def _create(**kw): - calls.append(kw) - if len(calls) == 1: - return MagicMock(stop_reason="tool_use", content=[block]) - t = MagicMock(type="text", text="done") - return MagicMock(stop_reason="end_turn", content=[t]) - - with patch.object(agent.client.messages, "create", side_effect=_create), \ - patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "save_reminder", - side_effect=lambda *a, **k: saved.append(a)): - agent.get_reply("+15550001111", "remind me", history=[]) - - results = [c for m in calls[-1]["messages"] for c in (m.get("content") or []) - if isinstance(c, dict) and c.get("type") == "tool_result"] - return results[0]["content"], saved - - def test_an_unsupported_recurrence_saves_nothing(self): - result, saved = self._set_reminder("monthly") - assert saved == [], "a repeat that can never re-arm must not be stored" - assert "Didn't save" in result - - def test_the_model_is_told_what_it_can_use_instead(self): - """A refusal with no alternative is how the model ends up apologising.""" - from palmer.timeutil import RECURRENCES - result, _ = self._set_reminder("every other tuesday") - for supported in RECURRENCES: - assert supported in result - assert "one-time reminder" in result - - def test_a_supported_recurrence_still_saves(self): - _, saved = self._set_reminder("daily") - assert len(saved) == 1 - assert saved[0][3] == "daily" - - def test_it_normalizes_the_way_the_send_path_does(self): - """next_occurrence strips and lowercases; what is stored must match.""" - _, saved = self._set_reminder(" Weekdays ") - assert saved[0][3] == "weekdays" - - def test_every_enum_value_in_the_schema_is_one_the_send_path_keeps(self): - """The schema and timeutil cannot be allowed to drift apart.""" - from palmer.timeutil import RECURRENCES - from palmer.tools_def import TOOLS - schema = next(t for t in TOOLS if t["name"] == "set_reminder") - enum = schema["input_schema"]["properties"]["recurrence"]["enum"] - assert set(enum) == set(RECURRENCES) diff --git a/tests/test_reminder_time.py b/tests/test_reminder_time.py deleted file mode 100644 index f8f2571..0000000 --- a/tests/test_reminder_time.py +++ /dev/null @@ -1,293 +0,0 @@ -"""The clock the model is given, and what happens to the time it hands back. - -Palmer used to be told the dyno's clock and the dyno's date, then asked to work -the user's timezone out from the city string in their profile. Two conversions -per reminder, in the model's head, off an anchor that is simply the wrong day -for every user west of UTC from 5pm local onward. At 17:42 on Sunday in Los -Angeles the server already reads Monday, so "remind me tomorrow at 9" files for -Tuesday. The model was not making a mistake; it was told the wrong day. - -These tests hold both halves of the fix: the prompt states the user's own day, -and the server vets what comes back rather than trusting the string. -""" -import importlib -from datetime import datetime, timedelta, timezone, date -from unittest.mock import patch - - -from palmer import agent -from palmer import db -from palmer import timeutil - - -PHONE = "+15550001111" - -# 2026-08-31T00:42Z is 2026-08-30 17:42 in Los Angeles — the exact window where -# the server's date and the user's date disagree. -ROLLOVER = datetime(2026, 8, 31, 0, 42, tzinfo=timezone.utc) - - -class TestValidZone: - def test_a_real_zone_resolves(self): - assert timeutil.valid_zone("America/Los_Angeles") == "America/Los_Angeles" - - def test_junk_is_rejected_rather_than_passed_through(self): - assert timeutil.valid_zone("Pacific Time") is None - assert timeutil.valid_zone("US/Notreal") is None - - def test_empty_is_none(self): - assert timeutil.valid_zone(None) is None - assert timeutil.valid_zone("") is None - - -class TestClockBlock: - def test_it_states_the_users_day_not_the_servers(self): - out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) - # The whole point: the server has already rolled over to Monday the - # 31st while the reader is still on Sunday the 30th. - assert "Sunday, August 30, 2026" in out # their today - assert "Monday August 31" in out # their tomorrow - assert "Monday, August 31" in out # the server's day, labelled - assert "America/Los_Angeles" in out - - def test_the_users_today_leads_and_the_server_is_an_aside(self): - out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) - assert out.index("Their local time") < out.index("Server clock") - assert "for your reference only" in out - - def test_the_local_hour_is_local(self): - out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) - assert "17:42" in out - - def test_no_zone_asserts_no_local_date(self): - out = timeutil.clock_block(None, now=ROLLOVER) - assert "don't know this person's timezone" in out - assert "Do not state or assume a local date" in out - # The failure being prevented: presenting the server's day as theirs. - assert "Their local time" not in out - - def test_an_unresolvable_zone_degrades_to_the_no_zone_form(self): - out = timeutil.clock_block("Pacific Time", now=ROLLOVER) - assert "don't know this person's timezone" in out - - def test_it_never_raises(self): - for bad in (None, "", "junk", 17, object()): - timeutil.clock_block(bad, now=ROLLOVER) - - -class TestTheClockReachesTheSystemPrompt: - def _build(self, profile: dict) -> str: - with patch.object(agent, "get_profile", return_value=profile), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - return agent._build_system(PHONE) - - def test_the_users_zone_is_named(self): - out = self._build({"name": "Ada", "timezone": "America/Chicago"}) - assert "America/Chicago" in out - assert "RIGHT NOW, WHERE THEY ARE" in out - - def test_a_profile_without_a_zone_gets_the_honest_form(self): - out = self._build({"name": "Ada"}) - assert "don't know this person's timezone" in out - - def test_base_system_renders_without_a_profile(self): - out = agent.base_system() - assert "RIGHT NOW" in out - assert "{" not in out.split("RIGHT NOW")[1][:200] - - -def _fresh(tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test_reminder_time.db") - db.init_db() - - -class TestNormalizeDueAt: - """The write-path vetting. `due_at` is TEXT and claim_due_reminders orders it - lexicographically, so a wrong shape is not a cosmetic problem — it fires the - reminder at the wrong hour.""" - - def _norm(self, raw, tz="America/Chicago", now=None): - with patch.object(agent, "get_profile", return_value={"timezone": tz}), \ - patch("palmer.agent.datetime") as dt: - dt.now.return_value = now or datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc) - dt.side_effect = datetime - return agent._normalize_due_at(PHONE, raw) - - def test_a_z_suffix_becomes_the_canonical_offset_form(self): - due, label, err = self._norm("2026-08-31T20:00:00Z") - assert err is None - assert due == "2026-08-31T20:00:00+00:00" - - def test_a_non_utc_offset_is_corrected_rather_than_read_as_utc(self): - # This is the five-hours-early bug. 09:00-05:00 is 14:00Z, and the old - # string compare read the literal "09:00" as though it were UTC. - due, label, err = self._norm("2026-08-31T09:00:00-05:00") - assert err is None - assert due == "2026-08-31T14:00:00+00:00" - - def test_a_naive_string_is_read_as_the_users_local_clock(self): - # A model that drops the offset was thinking in the user's day. Reading - # it as UTC would move the reminder by the whole offset. - due, label, err = self._norm("2026-08-31T09:00") - assert err is None - assert due == "2026-08-31T14:00:00+00:00" # 9am Chicago in August = CDT - - def test_a_naive_string_falls_back_to_utc_with_no_zone_on_file(self): - due, label, err = self._norm("2026-08-31T09:00", tz=None) - assert err is None - assert due == "2026-08-31T09:00:00+00:00" - assert "UTC" in label - - def test_a_past_time_is_refused_with_something_actionable(self): - due, label, err = self._norm("2026-08-29T09:00:00Z") - assert due is None - assert "already past" in err - assert "RIGHT NOW" in err # tells the model where to look - - def test_an_unreadable_time_is_refused(self): - due, label, err = self._norm("next thursday-ish") - assert due is None - assert err - - def test_absurdly_far_out_is_refused(self): - due, label, err = self._norm("2099-01-01T09:00:00Z") - assert due is None - assert err - - def test_the_label_is_local_and_carries_no_utc(self): - due, label, err = self._norm("2026-08-31T20:00:00Z") - assert "3:00 PM" in label # 20:00Z is 3pm CDT - assert "UTC" not in label - - -class TestTheDispatchEchoesLocalTime: - def test_a_saved_reminder_reports_the_local_time(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - with patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}): - due, label, err = agent._normalize_due_at( - PHONE, (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()) - assert err is None - # The old result echoed the raw UTC string, which made the model convert - # a second time for the half the user actually reads. - assert "+00:00" not in label - - -class TestStoredRowsAreCanonical: - def test_save_reminder_rewrites_the_offset(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_reminder(PHONE, "call mom", "2026-08-31T09:00:00-05:00") - conn = db._conn() - cur = conn.cursor() - cur.execute(f"SELECT due_at FROM reminders WHERE phone = {db.PH}", (PHONE,)) - stored = cur.fetchone()["due_at"] - conn.close() - assert stored == "2026-08-31T14:00:00+00:00" - - def test_an_unparseable_due_at_is_not_stored(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - db.save_reminder(PHONE, "call mom", "whenever") - conn = db._conn() - cur = conn.cursor() - cur.execute(f"SELECT COUNT(*) AS c FROM reminders WHERE phone = {db.PH}", (PHONE,)) - assert cur.fetchone()["c"] == 0 - conn.close() - - def test_normalize_repairs_a_legacy_row_and_is_idempotent(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - conn = db._conn() - cur = conn.cursor() - # Write past save_reminder, the way rows were written before it normalized. - cur.execute(f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", - (PHONE, "old row", "2026-08-31T09:00:00-05:00")) - conn.commit() - conn.close() - assert db.normalize_due_at_rows() == 1 - assert db.normalize_due_at_rows() == 0 - - def test_a_corrected_row_no_longer_fires_early(self, tmp_path, monkeypatch): - _fresh(tmp_path, monkeypatch) - # 09:00-05:00 is 14:00Z. Lexicographically "09:00" sorts below a 12:00Z - # "now", so before normalization this row was claimed five hours early. - conn = db._conn() - cur = conn.cursor() - cur.execute(f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", - (PHONE, "too early", "2999-01-01T09:00:00-05:00")) - conn.commit() - conn.close() - db.normalize_due_at_rows() - assert db.claim_due_reminders() == [] - - -class TestTheWeekIsATableToReadNotArithmetic: - """clock_block named "today" and "tomorrow" and emitted no ISO date at all, - so anything past tomorrow — "next Friday", "the 15th", "a week Tuesday" — - was the model rebuilding a date from the prose "Friday, September 04, - 2026" and counting forward in its head. That is the one computation on the - reminder path nothing checks: _normalize_due_at catches an unreadable - string, a past time and a date over a year out, but a plausible wrong - Friday sails through and reads correctly in the confirmation. - """ - - def _block(self): - return timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) - - def test_todays_iso_date_is_there_to_be_copied(self): - assert "Sun 2026-08-30 (today)" in self._block() - - def test_the_whole_week_is_dated(self): - out = self._block() - for day in ("Mon 2026-08-31", "Fri 2026-09-04", "Sat 2026-09-05"): - assert day in out - - def test_the_run_is_anchored_on_their_day_not_the_servers(self): - """The server has already rolled to Monday the 31st.""" - out = self._block() - assert out.index("Sun 2026-08-30") < out.index("Mon 2026-08-31") - assert "Mon 2026-08-30" not in out - - def test_todays_weekday_appears_twice(self): - """A bare weekday naming today means the one a week out — that rule - needs a second Sunday in the list to point at.""" - out = self._block() - assert out.count("2026-08-30") == 1 and "Sun 2026-09-06" in out - - def test_the_no_zone_form_labels_them_as_the_servers(self): - out = timeutil.clock_block(None, now=ROLLOVER) - assert "Server dates" in out - assert "Their local time" not in out - # The refusal is still the last word, which is the whole point of the branch. - assert "Do not state or assume a local date" in out - - def test_the_block_stays_small(self): - """It ships on every single turn, so its size is a property worth - holding, not a comment.""" - assert len(self._block()) < 650 - - -class TestOneAnswerForWhatNextFridayMeans: - def test_the_convention_lives_in_timeutil(self): - """It was in weather.py, so the only path where the MODEL computes a - date — reminders — had no answer for "next friday" while the weather - path had a considered one. The same user could get both in one thread.""" - from palmer.timeutil import resolve_day_delta - assert not hasattr(importlib.import_module("palmer.weather"), "_resolve_day_delta") - with patch.object(timeutil, "local_today", return_value=date(2026, 8, 26)): - assert resolve_day_delta("friday", "friday") == 2 # this Friday - assert resolve_day_delta("next friday", "next friday") == 9 - - def test_the_prompt_states_it_where_the_model_computes_the_date(self): - block = agent.SYSTEM_PROMPT.split("REMINDERS")[1].split("MORNING BRIEFING")[0] - assert "AFTER this coming one" in block - assert "read the date off" in block - - def test_the_prompt_says_what_to_do_with_an_unsupported_repeat(self): - """The write path refuses it; the model needs to know what to offer.""" - block = agent.SYSTEM_PROMPT.split("REMINDERS")[1].split("MORNING BRIEFING")[0] - assert "every other Tuesday" in block - assert "won't repeat" in block - - def test_the_due_at_field_points_at_the_block(self): - from palmer.tools_def import TOOLS - schema = next(t for t in TOOLS if t["name"] == "set_reminder") - assert "RIGHT NOW" in schema["input_schema"]["properties"]["due_at"]["description"] diff --git a/tests/test_repetition.py b/tests/test_repetition.py deleted file mode 100644 index a7fac79..0000000 --- a/tests/test_repetition.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Repetition, and the two opposite remedies it needs. - -Measured across every message Palmer has sent: 39 near-duplicate pairs for one -user, 11 for another. They were not one problem. - -SUPPRESSION — an unprompted message repeating one already sent. One user got the -identical followup twice, verbatim, because `_is_duplicate_subject` looked back -six hours while the followup job runs every four and the subject stayed live for -days. Another got "Here you go - " three times, word for word. - -VARIATION — a scheduled message the user DID ask for, said the same way every -time: "Morning Alex - 103 today in Cedar Falls", "106 in Cedar Falls -today, Alex", "111 today in Cedar Falls, Alex". Suppressing those would be -wrong; they asked for a daily briefing. Only the phrasing may not repeat. And -token overlap cannot see it — those score 0.23 against each other. - -LEAKAGE — a third thing found on the way: Palmer narrating its own filtering to -the reader, in the third person, about her. morning.py had a guard; the four -other senders never ran it. - -The corpora here are real messages. All offline. -""" -from unittest.mock import MagicMock, patch - -from palmer import guards - - -REPEAT_MORNINGS = [ - "Morning Alex - 103 today in Cedar Falls, stay inside if you can.", - "106 in Cedar Falls today, Alex - hottest it's been all week.", - "111 today in Cedar Falls, Alex - and there's actually a chance of thunderstorms.", - "110 in Cedar Falls today, so outdoor plans need a rethink.", -] -FRESH_LINES = [ - "Courtney Barnett plays the Hollywood Palladium Friday if you want a reason to get out.", - "Bitcoin's up 3.1% overnight - quiet week so that move's worth a look.", - "85 and sunny in Kirkwood, commute's clean at 17 minutes.", -] - - -class TestSuppressingVerbatimRepeats: - def test_the_identical_followup_is_caught(self): - line = "yo how'd practice look today? hurts moving like they said?" - assert guards.near_duplicate(line, [line]) == line - - def test_the_repeated_link_reply_is_caught(self): - a = "Here you go -\n\nhttps://palmer-app.example/h/abc" - b = "Here you go -\n\nhttps://palmer-app.example/h/abc" - assert guards.near_duplicate(a, [b]) - - def test_the_url_is_not_what_makes_them_similar(self): - """Two different sentences carrying the same page link are not repeats — - otherwise every message that ends in their URL reads as identical.""" - a = "Muse plays the Hollywood Bowl Sunday. https://x.example/h/abc" - b = "Bitcoin's up 3% overnight. https://x.example/h/abc" - assert guards.near_duplicate(a, [b]) is None - - def test_genuinely_different_messages_pass(self): - for i, line in enumerate(FRESH_LINES): - others = FRESH_LINES[:i] + FRESH_LINES[i + 1:] - assert guards.near_duplicate(line, others) is None, line - - def test_the_lexical_check_runs_before_any_model_call(self): - """The point is that a verbatim repeat costs nothing to catch, so it can - look back three days where the semantic check cannot afford to.""" - import inspect - from palmer import userprofile - src = inspect.getsource(userprofile._is_duplicate_subject) - assert src.index("near_duplicate") < src.index("client.messages.create") - assert userprofile.VERBATIM_WINDOW_HOURS > 24 - - def test_no_history_is_not_a_duplicate(self): - assert guards.near_duplicate("anything", []) is None - assert guards.near_duplicate("", ["anything"]) is None - - -class TestVaryingAScheduledMessage: - """The content is supposed to recur. The opening is not.""" - - def test_the_repeated_mornings_share_an_opening_shape(self): - shapes = {guards.opening_shape(m) for m in REPEAT_MORNINGS[1:]} - assert len(shapes) == 1, f"these three open identically: {shapes}" - - def test_a_repeat_is_detected_against_recent_sends(self): - assert guards.repeats_opening(REPEAT_MORNINGS[3], REPEAT_MORNINGS[:3]) - - def test_numbers_do_not_disguise_a_repeat(self): - """103 / 106 / 111 made every day look unique to a token comparison.""" - assert guards.similarity(REPEAT_MORNINGS[1], REPEAT_MORNINGS[2]) < 0.4 - assert guards.repeats_opening(REPEAT_MORNINGS[1], [REPEAT_MORNINGS[2]]) - - def test_a_genuinely_different_opening_is_left_alone(self): - for line in FRESH_LINES: - assert guards.repeats_opening(line, REPEAT_MORNINGS) is None, line - - def test_leading_with_something_else_clears_it(self): - """The redraft asks for the same facts starting somewhere else.""" - recast = "Courtney Barnett's at the Hollywood Palladium Friday, and it's 110 in Cedar Falls." - assert guards.repeats_opening(recast, REPEAT_MORNINGS) is None - - def test_the_morning_line_redrafts_on_a_repeat(self): - import inspect - from palmer import morning - src = inspect.getsource(morning.generate_morning_line) - assert "repeats_opening" in src - # Split across source lines in the literal, so match a fragment. - assert "number identical" in src, "a recast must not move the facts" - - -class TestDeliberationNeverShips: - LEAKED = [ - "Both of these fall into the crime/dark content category they explicitly " - "asked to avoid. Skipping.", - "This one's in the crime/dark content bucket they asked to avoid. Skipping it.", - ] - FINE = [ - "Muse plays the Hollywood Bowl Sunday if you need a reason to get out.", - "They beat the Pirates 4-1 last night - Mathews got his first career win.", - "You asked me to watch that fare - it's down to $668.", - "90 in Culver City today, low 70, barely a rain chance.", - # These four were BLOCKED by the either-signal version. Because - # send_sms returns False and main.py answers a falsy send with - # FALLBACK_SMS, the user got "something went sideways on my end, try - # again" in place of a perfectly good reply. Agreeing to stop doing - # something is not a leak, and neither is news about someone else. - "got it, not sending those anymore", - "noted - won't send you the crime stuff again", - "they said the deal closes Friday", - "they asked for a recount and the board agreed", - ] - - def test_the_real_leaks_are_caught(self): - for text in self.LEAKED: - assert guards.leaks_deliberation(text), text - - def test_ordinary_messages_survive(self): - """"They beat the Pirates" is a sentence about a baseball team, and - "you asked me to watch that fare" is Palmer talking TO someone.""" - for text in self.FINE: - assert not guards.leaks_deliberation(text), text - - def test_agreeing_to_stop_is_not_a_leak(self): - """The distinction the guard has to make: "not sending those anymore" is - a commitment TO the reader; "not sending, doesn't meet the threshold" is - Palmer explaining its plumbing to them.""" - assert not guards.leaks_deliberation("sure, not sending those anymore") - assert guards.leaks_deliberation("Not sending - it doesn't meet the threshold.") - - def test_news_about_a_third_party_survives(self): - """"said" is deliberately not an intent verb here: "they said the deal - closes Friday" is the sort of sentence Palmer exists to send.""" - for text in ("they said the deal closes Friday", - "they wanted a bigger deal and walked", - "she told me they prefer the early show"): - assert not guards.leaks_deliberation(text), text - - def test_naming_the_reader_as_the_user_is_damning_alone(self): - """Nobody texting a friend calls them "the user".""" - assert guards.leaks_deliberation("the user prefers shorter updates") - - def test_internal_machinery_is_damning_alone(self): - for text in ("scored below the bar so no alert needed", - "this one was filtered out", - "suppressing that one"): - assert guards.leaks_deliberation(text), text - - def test_paraphrase_does_not_escape_it(self): - """The guard it replaces matched fixed phrases, so the model wrote - around them — "they EXPLICITLY asked" missed a rule looking for - "they asked".""" - for text in ("the user specified no sports so leaving that out", - "This one doesn't meet the threshold, not sending.", - "They said they prefer lighter stuff, so I'll skip it."): - assert guards.leaks_deliberation(text), text - - def test_it_is_blocked_at_the_one_place_everything_passes_through(self): - """morning.py had a guard for this and four other senders never ran it.""" - import inspect - from palmer import sms_util - assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) - - def test_blocking_returns_false_rather_than_sending_something_else(self): - from palmer import sms_util - with patch.object(sms_util, "_twilio") as tw: - sent = sms_util.send_sms("+1555", self.LEAKED[0]) - assert sent is False - tw.messages.create.assert_not_called(), "nothing may go out, not even a fallback" - - -class TestADeliberationLeakInAReplyIsRedraftedNotDropped: - """send_sms blocks a leak outright, and for an unprompted message that is - exactly right — every real violation was a drafter announcing it had decided - NOT to send something, so doing that silently is what it was trying to do. - - On a reply it is the wrong trade. The user is waiting on an answer, and a - block there means main.py's falsy-send path hands them FALLBACK_SMS. So - _finalize redrafts first, and the send_sms block stays behind it.""" - - def _finalize(self, first, *retries): - from palmer import agent - calls = [] - - def _create(**kw): - calls.append(kw) - text = retries[min(len(calls) - 1, len(retries) - 1)] if retries else first - return MagicMock(content=[MagicMock(text=text)]) - - with patch.object(agent.client.messages, "create", side_effect=_create): - out, _ = agent._finalize(first, "sys", [{"role": "user", "content": "hi"}], None) - return out, calls - - def test_a_leak_is_redrafted_once(self): - out, calls = self._finalize( - "This one doesn't meet the threshold, not sending.", - "nothing worth flagging today") - assert "threshold" not in out - assert len(calls) == 1 - - def test_a_clean_reply_still_costs_nothing(self): - out, calls = self._finalize("sure, not sending those anymore") - assert calls == [] - assert out == "sure, not sending those anymore" - - def test_a_failed_redraft_keeps_the_original_rather_than_going_silent(self): - from palmer import agent - leak = "This one doesn't meet the threshold, not sending." - with patch.object(agent.client.messages, "create", side_effect=RuntimeError("down")): - out, _ = agent._finalize(leak, "sys", [], None) - assert out == leak, "send_sms is the backstop; _finalize must not blank it" - - def test_the_correction_tells_it_what_to_do_instead(self): - assert "TO them" in guards.DELIBERATION_CORRECTION - - def test_the_send_sms_block_is_still_there(self): - """The chokepoint stays — proactive senders never reach _finalize.""" - import inspect - from palmer import sms_util - assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) diff --git a/tests/test_reply_resilience.py b/tests/test_reply_resilience.py deleted file mode 100644 index 70379da..0000000 --- a/tests/test_reply_resilience.py +++ /dev/null @@ -1,203 +0,0 @@ -"""A turn that needs one tool call too many must still answer. - -get_reply capped the tool loop at six and RAISED past it. main.py catches that, -leaves `reply` as None, and answers a falsy reply with FALLBACK_SMS — so a turn -like "add Apple, Nvidia and Tesla, then what's my commute" died outright, threw -away every tool result it had already gathered, and told the user something went -sideways. Same shape as the deliberation guard: machinery meant to protect the -user producing the thing the user complained about. -""" -from unittest.mock import MagicMock, patch - -from palmer import agent - - -def _blocks(*, text=None, tool=None): - out = [] - if tool: - b = MagicMock() - b.type = "tool_use" - b.name = tool - b.id = "tu_1" - b.input = {"query": "x"} - del b.text # so `hasattr(b, "text")` is False - out.append(b) - if text is not None: - t = MagicMock() - t.type = "text" - t.text = text - out.append(t) - return out - - -class TestTrimToSentence: - def test_a_truncated_draft_is_cut_back_to_a_boundary(self): - out = agent._trim_to_sentence("Sure. 90 today, commute is 22 min. Anything el") - assert out == "Sure. 90 today, commute is 22 min." - - def test_a_complete_draft_is_untouched(self): - assert agent._trim_to_sentence("90 today.") == "90 today." - - def test_no_boundary_at_all_keeps_the_fragment(self): - """A short fragment still beats nothing — there is no second draft.""" - assert agent._trim_to_sentence("no boundary here") == "no boundary here" - - def test_it_never_returns_something_uselessly_short(self): - # Trimming "Ok. " back to "Ok." would be worse - # than shipping the fragment. - out = agent._trim_to_sentence("Ok. and then the thing about the fare wa") - assert out.startswith("Ok. and then") - - def test_empty_is_safe(self): - assert agent._trim_to_sentence("") == "" - - -class TestMaxTokensDoesNotShipHalfASentence: - def test_the_reply_is_trimmed(self): - resp = MagicMock(stop_reason="max_tokens", - content=_blocks(text="Sure. 90 today. Anything el")) - with patch.object(agent.client.messages, "create", return_value=resp), \ - patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_profile", return_value={}), \ - patch.object(agent, "get_history", return_value=[]): - out, _ = agent.get_reply("+15550001111", "hi", history=[]) - assert out == "Sure. 90 today." - - -class TestTheLoopAnswersInsteadOfDying: - def _run(self, final_text): - """Every response asks for another tool, so the cap is always reached.""" - calls = [] - - def _create(**kw): - calls.append(kw) - if "tools" not in kw: # the final, tool-less ask - return MagicMock(stop_reason="end_turn", - content=_blocks(text=final_text)) - return MagicMock(stop_reason="tool_use", content=_blocks(tool="web_search")) - - with patch.object(agent.client.messages, "create", side_effect=_create), \ - patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_profile", return_value={}), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "_search", return_value="some results"): - out, _ = agent.get_reply("+15550001111", "do lots", history=[]) - return out, calls - - def test_it_returns_an_answer_rather_than_raising(self): - out, _ = self._run("here's what I found, in short") - assert out == "here's what I found, in short" - - def test_the_final_ask_has_no_tools(self): - """Tools are taken away so the model must answer from what it gathered.""" - _, calls = self._run("answer") - assert "tools" not in calls[-1] - - def test_it_stops_at_the_cap(self): - _, calls = self._run("answer") - # One call per iteration, plus the final tool-less one. - assert len(calls) == agent.TOOL_ITERATION_CAP + 1 - - def test_the_gathered_work_is_carried_into_the_final_ask(self): - _, calls = self._run("answer") - assert len(calls[-1]["messages"]) > 1, "tool results must survive" - - def test_the_cap_is_high_enough_for_ordinary_asks(self): - """Three tickers plus a commute is five calls before Palmer speaks.""" - assert agent.TOOL_ITERATION_CAP >= 8 - - -class TestAToolThatRaisesDoesNotLoseTheTurn: - """The same failure as the cap, through a different door. - - Nothing wrapped the dispatch chain, so a raise anywhere in it escaped - get_reply, main.py answered the falsy reply with FALLBACK_SMS, and every - tool result already gathered went with it. In a three-intent turn one - failing DB write destroyed the two intents that had already succeeded. - """ - - def _run(self, search_side_effect, final_text="ok, here's what I have"): - calls = [] - - def _create(**kw): - calls.append(kw) - if len(calls) == 1: - return MagicMock(stop_reason="tool_use", content=_blocks(tool="web_search")) - return MagicMock(stop_reason="end_turn", content=_blocks(text=final_text)) - - with patch.object(agent.client.messages, "create", side_effect=_create), \ - patch.object(agent, "_build_system", return_value="sys"), \ - patch.object(agent, "get_profile", return_value={}), \ - patch.object(agent, "get_history", return_value=[]), \ - patch.object(agent, "_search", side_effect=search_side_effect): - out, _ = agent.get_reply("+15550001111", "what's the news", history=[]) - return out, calls - - def _tool_results(self, calls): - """The tool_result blocks handed back on the follow-up call.""" - out = [] - for m in calls[-1]["messages"]: - content = m.get("content") - if isinstance(content, list): - out += [c for c in content if isinstance(c, dict) - and c.get("type") == "tool_result"] - return out - - def test_the_turn_still_answers(self): - out, _ = self._run(RuntimeError("boom")) - assert out == "ok, here's what I have" - - def test_the_error_comes_back_as_a_tool_result(self): - _, calls = self._run(RuntimeError("boom")) - results = self._tool_results(calls) - # Every tool_use block must be answered or the next call is rejected. - assert len(results) == 1 - assert results[0]["tool_use_id"] == "tu_1" - assert "web_search" in results[0]["content"] - - def test_a_raise_is_still_loud_in_the_logs(self, capsys): - self._run(RuntimeError("boom")) - err = capsys.readouterr() - assert "TOOL web_search raised: RuntimeError: boom" in err.out - assert "Traceback" in err.err - - def test_keyboard_interrupt_is_not_swallowed(self): - """`except Exception`, never `BaseException` — a ctrl-c must still stop.""" - try: - self._run(KeyboardInterrupt()) - except KeyboardInterrupt: - return - assert False, "KeyboardInterrupt was swallowed by the tool guard" - - -class TestWhatTheModelIsToldWhenAToolRaises: - def test_it_never_claims_a_missing_capability(self): - from palmer import guards - out = agent._tool_error("search_flights", RuntimeError("x")) - assert not guards.redirects_elsewhere(out) - assert "not a missing capability" in out - - def test_it_does_not_leak_a_query_string(self): - """netutil re-raises the urllib error, and that URL carries the API key.""" - exc = RuntimeError( - "HTTP Error 500: https://serpapi.com/search.json?api_key=SECRETKEY&q=x") - out = agent._tool_error("search_shopping", exc) - assert "SECRETKEY" not in out - assert "api_key" not in out - assert "RuntimeError" in out # the type still reaches the model - - def test_an_argument_error_is_actionable(self): - """Our own strings carry no secrets and are the only ones it can fix.""" - out = agent._tool_error("set_reminder", KeyError("due_at")) - assert "due_at" in out - assert "once more" in out - - def test_the_cache_invalidate_guards_are_left_alone(self): - """They wrap a best-effort expiry AFTER a successful write. - - Folding them into the outer catch would turn a stale cache into a - tool error for an operation that actually succeeded — Palmer would - tell the user their topic wasn't added when it was.""" - import inspect - src = inspect.getsource(agent.get_reply) - assert src.count("home.invalidate after") >= 6 diff --git a/tests/test_rubrics.py b/tests/test_rubrics.py deleted file mode 100644 index 0e7fe8a..0000000 --- a/tests/test_rubrics.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Tests for the genre classifier and per-genre rubrics. - -Haiku is mocked; we're testing that: - 1. The classifier normalizes Haiku's reply into a VALID_GENRES value. - 2. In-process memoization actually short-circuits the second call. - 3. Every declared genre has a non-empty rubric. - 4. rubric_for() falls back safely on unknown or None input. -""" -from unittest.mock import patch, MagicMock - -from palmer import rubrics - - -def _haiku_reply(text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - -class TestRubricCoverage: - def test_every_genre_has_content(self): - for g in rubrics.VALID_GENRES: - body = rubrics.GENRE_RUBRICS.get(g, "") - assert body.strip(), f"genre {g!r} has empty rubric" - # Rubric must have both sides ("would text" and "wouldn't") - low = body.lower() - assert "would text about" in low, f"genre {g!r} missing 'would text about' block" - assert "nobody would text" in low or g == "other", \ - f"genre {g!r} missing 'nobody would text' block" - - def test_rubric_for_known_genre(self): - assert rubrics.rubric_for("sports_team") == rubrics.GENRE_RUBRICS["sports_team"] - - def test_rubric_for_none_falls_back_to_other(self): - assert rubrics.rubric_for(None) == rubrics.GENRE_RUBRICS["other"] - - def test_rubric_for_unknown_falls_back_to_other(self): - assert rubrics.rubric_for("kittens") == rubrics.GENRE_RUBRICS["other"] - - -class TestClassifyGenre: - def setup_method(self): - rubrics._reset_cache_for_tests() - - def test_exact_reply_maps(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("sports_team") - assert rubrics.classify_genre("Cardinals") == "sports_team" - - def test_normalizes_reply_with_punctuation(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("Category: market_instrument.") - assert rubrics.classify_genre("AAPL stock") == "market_instrument" - - def test_uppercase_reply_maps(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("GEOPOLITICS") - assert rubrics.classify_genre("Iran conflict") == "geopolitics" - - def test_unknown_reply_falls_back_to_other(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("nonsense reply here") - assert rubrics.classify_genre("something") == "other" - - def test_api_failure_falls_back_to_other(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.side_effect = RuntimeError("boom") - assert rubrics.classify_genre("anything") == "other" - - def test_empty_topic_returns_other_without_api_call(self): - with patch("palmer.rubrics.client") as mock_client: - assert rubrics.classify_genre("") == "other" - assert rubrics.classify_genre(" ") == "other" - mock_client.messages.create.assert_not_called() - - def test_memoization_short_circuits_second_call(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("sports_team") - rubrics.classify_genre("Cardinals") - rubrics.classify_genre("Cardinals") - rubrics.classify_genre("cardinals") # case-insensitive - rubrics.classify_genre(" Cardinals ") # trim - assert mock_client.messages.create.call_count == 1 - - def test_memoization_distinct_topics_call_separately(self): - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.side_effect = [ - _haiku_reply("sports_team"), - _haiku_reply("market_instrument"), - ] - assert rubrics.classify_genre("Cardinals") == "sports_team" - assert rubrics.classify_genre("AAPL") == "market_instrument" - assert mock_client.messages.create.call_count == 2 - - def test_failure_result_is_cached(self): - """Once 'other' is cached for a topic, a later successful reply won't override it. - This is deliberate — repeated Haiku hits on the same string are wasted budget.""" - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.side_effect = RuntimeError("boom") - assert rubrics.classify_genre("weird") == "other" - with patch("palmer.rubrics.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("sports_team") - assert rubrics.classify_genre("weird") == "other" - mock_client.messages.create.assert_not_called() diff --git a/tests/test_scheduler_config.py b/tests/test_scheduler_config.py deleted file mode 100644 index 337613f..0000000 --- a/tests/test_scheduler_config.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Scheduler registration properties that are invisible at runtime. - -Every job here failed, or could fail, the same way: an APScheduler *interval* -job schedules its first run at `start + interval`, and that clock restarts on -every dyno boot — which means every deploy. A job whose period is long relative -to the gap between deploys therefore runs on a cadence set by deploy history -rather than by the clock, and since a tick that finds nothing to do logs -nothing, it fails silently. run_price_watches at 12h only ran on days -production was left alone; run_followups at 4h had 1.5 ticks of margin against -a 6h delivery window before the reset was even considered. - -The property under test throughout is phase-independence: fire times must not -move when the process starts at a different moment. -""" -import importlib -from dotenv import load_dotenv -load_dotenv() - -from datetime import datetime, timedelta, timezone -from unittest.mock import patch -from zoneinfo import ZoneInfo - -import pytest -from apscheduler.triggers.cron import CronTrigger - - -def _scheduler(): - with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - from palmer import main - return main._scheduler - - -def _trigger_for(func): - jobs = [j for j in _scheduler().get_jobs() if j.func is func] - assert len(jobs) == 1, f"expected exactly one job for {func.__name__}" - return jobs[0].trigger - - -def _fire_times(trigger, start, count): - out, fire = [], None - for _ in range(count): - fire = trigger.get_next_fire_time(fire, start if fire is None else fire + timedelta(seconds=1)) - out.append(fire) - return out - - -# Jobs whose period is an hour or longer must be phase-stable. The short jobs -# (reminders 1m, morning 5m, watches 30m) stay on interval deliberately — -# losing up to 30 minutes to a deploy is immaterial there. -def _long_period_jobs(): - from palmer.followup import run_followups - from palmer.morning import send_missing_data_asks - from palmer.shopping import run_price_watches - from palmer.flightwatch import run_flight_watches - from palmer.wxaudit import run_forecast_audit - return [run_followups, send_missing_data_asks, run_price_watches, - run_flight_watches, run_forecast_audit] - - -@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) -def test_long_period_jobs_are_cron(func): - assert isinstance(_trigger_for(func), CronTrigger) - - -@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) -def test_long_period_jobs_fire_on_round_clock_positions(func): - """Phase-independence is structural once the trigger is cron — a CronTrigger - holds no boot-derived state, where an IntervalTrigger's grid is anchored to - the start_date it was given at add_job time. What is checkable here is the - visible consequence: fire times sit on round clock positions rather than on - whatever minute the dyno happened to boot at.""" - trigger = _trigger_for(func) - for fire in _fire_times(trigger, datetime(2026, 8, 25, 3, 17, tzinfo=timezone.utc), 6): - utc = fire.astimezone(timezone.utc) - assert utc.second == 0 and utc.microsecond == 0 - assert utc.minute in (0, 30), f"{func.__name__} fires at :{utc.minute:02d}" - - -@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) -def test_long_period_jobs_pin_their_timezone(func): - """A bare BackgroundScheduler() inherits the PROCESS timezone. That is - Etc/UTC on the dyno and the developer's own zone locally, so an unpinned - cron grid means local runs disagree with production while both look right — - and a TZ config var would rotate the live schedule with nothing to show for - it. Caught exactly this: three jobs registered as America/Chicago on a - laptop and Etc/UTC in prod.""" - assert str(_trigger_for(func).timezone) in ("UTC", "Etc/UTC") - - -@pytest.mark.parametrize("func", _long_period_jobs(), ids=lambda f: f.__name__) -def test_long_period_jobs_survive_a_delayed_tick(func): - """APScheduler's default misfire_grace_time is 1 second, which drops a tick - delayed behind a slow job instead of running it late.""" - jobs = [j for j in _scheduler().get_jobs() if j.func is func] - assert jobs[0].misfire_grace_time and jobs[0].misfire_grace_time >= 600 - - -class TestFollowupGrid: - """run_followups gates on a 13:00-19:00 window in the USER's timezone, so the - grid has to land inside that window for every timezone served — not just for - whichever one the UTC hours were picked against.""" - - SERVED = ("America/Chicago", "America/Los_Angeles") - - def _ticks_in_window(self, tz_name): - trigger = _trigger_for(importlib.import_module("palmer.followup").run_followups) - start = datetime(2026, 8, 25, 0, 0, tzinfo=timezone.utc) - fires = _fire_times(trigger, start, 24) # a full day at 2h spacing - return [f for f in fires - if 13 <= f.astimezone(ZoneInfo(tz_name)).hour < 19 - and f < start + timedelta(days=1)] - - @pytest.mark.parametrize("tz_name", SERVED) - def test_window_gets_multiple_chances(self, tz_name): - # The old 4h interval gave 1.5 ticks of margin against this 6h window. - assert len(self._ticks_in_window(tz_name)) >= 3 - - def test_grid_is_timezone_agnostic(self): - # Both zones get the same coverage — the property that keeps working as - # users are added in zones nobody picked hours for. - counts = {tz: len(self._ticks_in_window(tz)) for tz in self.SERVED} - assert len(set(counts.values())) == 1, counts - - -class TestNoUnpromptedSenderComesBack: - """A daily "a friend would text this" news alert used to text people on - Palmer's own initiative. It is gone; run_followups is the one paced - check-in, and run_score_alerts only texts teams a user set a live level - on. This pins that nothing quietly comes back.""" - - def test_the_retired_modules_are_gone(self): - import importlib - for name in ("palmer.alerts",): - try: - importlib.import_module(name) - except ModuleNotFoundError: - continue - raise AssertionError(f"{name} is back") - - def test_the_job_list_is_exactly_this(self): - allowed = {"send_due_reminders", "send_morning_messages", "run_watches", - "send_missing_data_asks", "run_followups", "run_price_watches", - "run_forecast_audit", "run_flight_watches", "run_score_alerts"} - names = {j.func.__name__ for j in _scheduler().get_jobs()} - assert names == allowed, names - - -class TestShortJobsStayOnInterval: - """Not everything should be cron. These are frequent enough that a deploy - reset costs less than the added rigidity is worth.""" - - def test_short_jobs_are_interval(self): - from apscheduler.triggers.interval import IntervalTrigger - from palmer.reminders import send_due_reminders - from palmer.morning import send_morning_messages - from palmer.watches import run_watches - for func in (send_due_reminders, send_morning_messages, run_watches): - assert isinstance(_trigger_for(func), IntervalTrigger), func.__name__ diff --git a/tests/test_shopping.py b/tests/test_shopping.py new file mode 100644 index 0000000..9db47c2 --- /dev/null +++ b/tests/test_shopping.py @@ -0,0 +1,999 @@ +"""Price watches: Google Shopping, Amazon, the alert drafter, the daily cap. + +Merged from test_price_watches.py, test_price_daily_cap.py, test_price_alert.py, test_amazon_watches.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +from datetime import datetime, timezone, timedelta, date +from unittest.mock import patch, MagicMock +from palmer.shopping import ( + _cooldown_ok, + _should_alert, + MOVE_MIN_ABS, + _filter_and_sort, + _is_marketplace_thirdparty, + _brand_tokens, + browse_shop, + _daily_ok, + PRICE_DAILY_ALERT_MAX, + check_price, +) +from palmer import price_alert, amazon, shopping +from tests.helpers import llm_reply + + +# ============================================================================ +# from test_price_watches.py +# ============================================================================ +# +# Tests for price-watch alert logic. Pure logic only — no SerpAPI / Anthropic +# calls. Run: pytest tests/test_shopping.py + +def _watch(**kwargs) -> dict: + """Build a watch dict with sensible defaults; override fields per-test.""" + base = { + "id": 1, + "phone": "+15551234567", + "product_name": "Nike Pegasus 40", + "target_price": None, + "currency": "USD", + "baseline_price": None, + "last_seen_price": None, + "cooldown_hours": 12, + "last_alerted": None, + } + base.update(kwargs) + return base + + +class TestShouldAlert: + def test_no_baseline_no_target_never_alerts(self): + assert _should_alert(_watch(), 50.0) == "" + + def test_target_hit_alerts(self): + assert _should_alert(_watch(target_price=100.0), 100.0) == "target" + assert _should_alert(_watch(target_price=100.0), 99.99) == "target" + + def test_target_not_hit_no_alert(self): + assert _should_alert(_watch(target_price=100.0), 100.01) == "" + + def test_drop_over_the_bar_alerts(self): + assert _should_alert(_watch(baseline_price=50.98), 47.98) == "drop" + assert _should_alert(_watch(baseline_price=200.0), 150.0) == "drop" + + def test_move_under_the_bar_is_silent(self): + assert _should_alert(_watch(baseline_price=50.98), 49.50) == "" + assert _should_alert(_watch(baseline_price=200.0), 198.50) == "" + assert _should_alert(_watch(baseline_price=200.0), 201.50) == "" + + def test_bar_is_flat_not_proportional(self): + # The rule is "$2 on any product", so an expensive item is NOT held to a + # bigger move. A percentage bar (briefly max(5%, $2)) needed $10 here. + assert _should_alert(_watch(baseline_price=200.0), 197.50) == "drop" + assert _should_alert(_watch(baseline_price=1200.0), 1197.50) == "drop" + # ...and a cheap item clears it on the same absolute move. + assert _should_alert(_watch(baseline_price=12.00), 9.50) == "drop" + + def test_rise_over_the_bar_alerts(self): + # A material move UP is worth a text too — it's the cue to buy now. + assert _should_alert(_watch(baseline_price=50.98), 53.98) == "rise" + assert _should_alert(_watch(baseline_price=200.0), 202.50) == "rise" + + def test_boundary_is_inclusive_both_ways(self): + assert _should_alert(_watch(baseline_price=100.0), 98.00) == "drop" + assert _should_alert(_watch(baseline_price=100.0), 102.00) == "rise" + assert _should_alert(_watch(baseline_price=100.0), 98.01) == "" + assert _should_alert(_watch(baseline_price=100.0), 101.99) == "" + + def test_target_takes_precedence_over_drop(self): + # Both would trigger; expect 'target' since that's the user's explicit ask + assert _should_alert(_watch(target_price=150.0, baseline_price=200.0), 140.0) == "target" + + def test_a_rise_never_reports_as_a_target_hit(self): + # target is a ceiling: a price above it must not read as "you got it". + assert _should_alert(_watch(target_price=45.0, baseline_price=50.0), 55.0) == "rise" + + def test_move_constant_is_reasonable(self): + assert 0.0 < MOVE_MIN_ABS < 10.0 # a materiality floor, in dollars + + +class TestCooldownOk: + def test_never_alerted_ok(self): + assert _cooldown_ok(_watch(last_alerted=None)) + + def test_recent_alert_blocked(self): + recent = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + assert not _cooldown_ok(_watch(last_alerted=recent, cooldown_hours=12)) + + def test_stale_alert_ok(self): + stale = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() + assert _cooldown_ok(_watch(last_alerted=stale, cooldown_hours=12)) + + def test_exactly_at_cooldown_ok(self): + now = datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc) + past = (now - timedelta(hours=12)).isoformat() + assert _cooldown_ok(_watch(last_alerted=past, cooldown_hours=12), now=now) + + def test_malformed_timestamp_treated_as_ok(self): + # Defensive: don't block forever on bad data + assert _cooldown_ok(_watch(last_alerted="not-a-date")) + + def test_z_suffix_iso_parses(self): + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") + assert not _cooldown_ok(_watch(last_alerted=past, cooldown_hours=12)) + + +class TestFilterAndSort: + def _sample(self) -> list[dict]: + return [ + {"title": "Reebok Classic Leather", "price": 92.0, "merchant": "Zappos", "url": ""}, + {"title": "Reebok Nano X4", "price": 140.0, "merchant": "reebok.com", "url": ""}, + {"title": "Reebok Club C 85", "price": 75.0, "merchant": "Amazon", "url": ""}, + {"title": "Reebok Floatride", "price": 180.0, "merchant": "Nordstrom", "url": ""}, + {"title": "Reebok kids shoe", "price": 35.0, "merchant": "Target", "url": ""}, + ] + + def test_sorts_cheapest_first(self): + out = _filter_and_sort(self._sample(), None, None, 10) + prices = [r["price"] for r in out] + assert prices == sorted(prices) + + def test_max_price_filter(self): + out = _filter_and_sort(self._sample(), max_price=100.0, min_price=None, limit=10) + assert all(r["price"] <= 100.0 for r in out) + assert len(out) == 3 # 35, 75, 92 + + def test_min_price_filter(self): + out = _filter_and_sort(self._sample(), max_price=None, min_price=100.0, limit=10) + assert all(r["price"] >= 100.0 for r in out) + assert len(out) == 2 # 140, 180 + + def test_band_filter(self): + out = _filter_and_sort(self._sample(), max_price=150.0, min_price=70.0, limit=10) + assert [r["price"] for r in out] == [75.0, 92.0, 140.0] + + def test_limit_caps_results(self): + out = _filter_and_sort(self._sample(), None, None, 2) + assert len(out) == 2 + assert [r["price"] for r in out] == [35.0, 75.0] + + def test_empty_input(self): + assert _filter_and_sort([], None, None, 5) == [] + + +class TestMarketplaceThirdparty: + def test_ebay_individual_seller_is_thirdparty(self): + assert _is_marketplace_thirdparty("eBay - dabondo1") + assert _is_marketplace_thirdparty("eBay - sli00uo0rtoi") + + def test_named_marketplaces_are_thirdparty(self): + for m in ("Poshmark", "Mercari", "Depop", "Vinted", "Grailed"): + assert _is_marketplace_thirdparty(m), m + + def test_real_retailers_not_thirdparty(self): + for m in ("Madewell", "Nordstrom Rack", "Zappos", "Amazon", "shopbop.com", "Allen Edmonds"): + assert not _is_marketplace_thirdparty(m), m + + def test_link_mode_sort_pushes_thirdparty_last(self): + # Google-ordered results: cheap eBay listing shouldn't win link mode + results = [ + {"merchant": "eBay - randomseller", "price": 100.0}, + {"merchant": "Nordstrom Rack", "price": 180.0}, + {"merchant": "shopbop.com", "price": 212.0}, + ] + ranked = sorted(results, key=lambda r: _is_marketplace_thirdparty(r["merchant"])) + # Original order preserved for non-third-party; eBay falls to end + assert ranked[0]["merchant"] == "Nordstrom Rack" + assert ranked[-1]["merchant"].startswith("eBay - ") + + +class TestBrandTokens: + def test_extracts_multi_word_brand(self): + assert "allen" in _brand_tokens("Allen Edmonds Newman Penny Loafer") + assert "edmonds" in _brand_tokens("Allen Edmonds Newman Penny Loafer") + + def test_short_words_ignored(self): + # 'the', 'a', 'is' should not become tokens + tokens = _brand_tokens("the Nike Air") + assert "the" not in tokens + assert "nike" in tokens + + def test_non_alpha_ignored(self): + tokens = _brand_tokens("WH-1000XM5 Sony headphones") + assert "sony" in tokens + assert "headphones" in tokens + # WH-1000XM5 contains non-alpha, should be dropped + assert not any("1000" in t for t in tokens) + + def test_empty_query(self): + assert _brand_tokens("") == [] + + +class TestBaselineWorkflow: + """The baseline-then-alert two-phase flow: first check silently records, + later checks fire. We simulate the state machine at the dict level.""" + + def test_first_check_no_alert_after_baseline_set(self): + w = _watch(baseline_price=None, target_price=None) + # No baseline, no target → run_price_watches would set baseline and NOT alert. + assert _should_alert(w, 200.0) == "" + + def test_second_check_alerts_on_meaningful_drop(self): + # After baseline was set at $200 on tick 1, tick 2 sees $150 → alert + w = _watch(baseline_price=200.0) + assert _should_alert(w, 150.0) == "drop" + + def test_second_check_no_alert_on_noise(self): + # After baseline at $200, tick 2 sees $199.20 — an $0.80 wobble, under + # the flat $2 bar. ($195 used to count as noise here; under a flat bar a + # $5 move on any product is material, which is the point of the change.) + w = _watch(baseline_price=200.0) + assert _should_alert(w, 199.20) == "" + + def test_alerted_price_becomes_the_new_baseline(self): + # update_price_watch_alerted re-baselines to the alerted price, so the + # same discount does not re-qualify on the next tick — only a further + # drop does. Simulated at the dict level, like the rest of this class. + w = _watch(baseline_price=200.0) + assert _should_alert(w, 150.0) == "drop" + w["baseline_price"] = 150.0 # what the alert write now does + assert _should_alert(w, 150.0) == "" # still cheap, but already told + assert _should_alert(w, 147.0) == "drop" # a further $3 move does fire + + +class TestBrowseShop: + """Pure-logic coverage for browse_shop's ranking + aggregator-skip rules. + _http_get_json is mocked so no network calls.""" + + def _serp(self, organic=None, knowledge_graph=None) -> dict: + return { + "organic_results": organic or [], + "knowledge_graph": knowledge_graph or {}, + } + + def test_knowledge_graph_wins_when_brand_matches(self): + payload = self._serp( + organic=[{"title": "Buzzfeed roundup", "link": "https://www.buzzfeed.com/best-tees"}], + knowledge_graph={"title": "Madewell", "website": "https://www.madewell.com"}, + ) + with patch("palmer.serpapi._http_get_json", return_value=payload): + out = browse_shop("Madewell mens tee shirts") + assert "madewell.com" in out + + def test_brand_organic_beats_top_ranked_listicle(self): + payload = self._serp(organic=[ + {"title": "Best T-Shirts 2026", "link": "https://www.buzzfeed.com/tees"}, + {"title": "Men's T-Shirts | Madewell", "link": "https://www.madewell.com/mens/tshirts"}, + ]) + with patch("palmer.serpapi._http_get_json", return_value=payload): + out = browse_shop("Madewell mens tee shirts") + assert "madewell.com/mens/tshirts" in out + assert "buzzfeed" not in out + + def test_aggregators_skipped(self): + # No brand token match; walk past pinterest/reddit/google to real store + payload = self._serp(organic=[ + {"title": "Pinterest pins", "link": "https://www.pinterest.com/x"}, + {"title": "Reddit thread", "link": "https://www.reddit.com/r/malefashionadvice/x"}, + {"title": "Actual store", "link": "https://www.uniqlo.com/us/en/men/tops/t-shirts"}, + ]) + with patch("palmer.serpapi._http_get_json", return_value=payload): + out = browse_shop("mens tee shirts") + assert "uniqlo.com" in out + assert "pinterest" not in out + assert "reddit" not in out + + def test_amazon_search_page_skipped_product_page_allowed(self): + search_page = self._serp(organic=[ + {"title": "Amazon search", "link": "https://www.amazon.com/s?k=wool+coat"}, + {"title": "Wool coat", "link": "https://www.amazon.com/dp/B0XXXX"}, + ]) + with patch("palmer.serpapi._http_get_json", return_value=search_page): + out = browse_shop("wool coat") + assert "/dp/B0XXXX" in out + assert "/s?k=" not in out + + def test_falls_back_to_top_valid_organic_when_no_brand_match(self): + payload = self._serp(organic=[ + {"title": "Random shop", "link": "https://www.someshop.com/x"}, + {"title": "Another shop", "link": "https://www.othershop.com/y"}, + ]) + with patch("palmer.serpapi._http_get_json", return_value=payload): + out = browse_shop("wool coat") + assert "someshop.com" in out + + def test_empty_organic_returns_no_result_string(self): + """Empty, and said as an empty result rather than a dead end. + + This used to assert the literal "No browse result found", which told + the model nothing about what to do next — the shape that gets + paraphrased into a refusal and then into another store.""" + from palmer import guards + with patch("palmer.serpapi._http_get_json", return_value=self._serp()): + out = browse_shop("Madewell tees") + assert "DO have this" in out + assert "confirm the brand" in out + assert not guards.redirects_elsewhere(out) + + def test_serpapi_failure_returns_no_result_string(self): + from palmer import guards + with patch("palmer.serpapi._http_get_json", return_value=None): + out = browse_shop("Madewell tees") + assert "DO have this" in out + assert not guards.redirects_elsewhere(out) + + def test_missing_api_key_returns_unavailable(self): + with patch("palmer.serpapi.API_KEY", ""): + out = browse_shop("Madewell tees") + # The string must not imply the capability does not exist — that is what + # the model paraphrased into "I can't do that, try somewhere else". + low = out.lower() + assert "failed to run" in low and "try again" in low + assert "do have product search" in low + for rival in ("google", "amazon.com", "another store"): + assert f"try {rival}" not in low + + +class TestPriceWatchSchedule: + """run_price_watches must be on a fixed-hour cron, not an interval. + + An interval job's first run is start + interval, and the clock restarts on + every dyno boot — i.e. every deploy. At a 12h cadence that meant the job + only ever ran on days production was left alone for 12 straight hours; on a + day with four deploys it never ran at all, and a tick that finds nothing + logs nothing, so it failed silently. The property under test is that the + fire times are a function of the clock, not of when the process started.""" + + def _trigger(self): + from palmer import main + from palmer.shopping import run_price_watches + jobs = [j for j in main._scheduler.get_jobs() if j.func is run_price_watches] + assert len(jobs) == 1, "expected exactly one run_price_watches job" + return jobs[0].trigger + + def test_is_a_cron_trigger(self): + from apscheduler.triggers.cron import CronTrigger + assert isinstance(self._trigger(), CronTrigger) + + def test_phase_is_independent_of_process_start(self): + # The regression an interval trigger would reintroduce: boot at two + # different moments and the schedule must not move. + from datetime import datetime, timedelta, timezone + trigger = self._trigger() + boot_a = datetime(2026, 8, 25, 3, 17, tzinfo=timezone.utc) + boot_b = boot_a + timedelta(hours=5, minutes=42) # a later deploy + assert (trigger.get_next_fire_time(None, boot_a) + == trigger.get_next_fire_time(None, boot_b)) + + def test_runs_exactly_twice_a_day(self): + # The SerpAPI budget is denominated in runs per day, not in evenness of + # spacing — these slots are 16h and 8h apart on purpose (see main.py). + from datetime import datetime, timedelta, timezone + trigger = self._trigger() + start = datetime(2026, 8, 25, 0, 0, tzinfo=timezone.utc) + fire, fires = None, [] + while True: + fire = trigger.get_next_fire_time(fire, start if fire is None else fire + timedelta(seconds=1)) + if fire >= start + timedelta(days=1): + break + fires.append(fire) + assert len(fires) == 2, fires + + def test_fires_during_waking_hours_for_served_timezones(self): + # These are unprompted texts. Both user timezones on record must land in + # daytime, or the cadence is correct and the experience still bad. + from datetime import datetime, timedelta, timezone + from zoneinfo import ZoneInfo + trigger = self._trigger() + fire = None + for _ in range(4): + start = datetime(2026, 8, 25, 1, 0, tzinfo=timezone.utc) if fire is None else fire + timedelta(seconds=1) + fire = trigger.get_next_fire_time(fire, start) + for tz in ("America/Chicago", "America/Los_Angeles"): + hour = fire.astimezone(ZoneInfo(tz)).hour + assert 8 <= hour <= 21, f"{fire} is {hour}:00 in {tz}" + + +# ============================================================================ +# from test_price_daily_cap.py +# ============================================================================ +# +# Tests for the per-watch daily cap and marketplace-filter added to price watches. +# The cap prevents a price oscillating across the drop threshold from re-firing +# indefinitely on the 12-hour cadence. + +class TestDailyOk: + def test_no_history_ok(self): + assert _daily_ok({"daily_alert_count": 0, "daily_alert_date": None}) + + def test_prior_day_resets(self): + yesterday = (date.today() - timedelta(days=1)).isoformat() + assert _daily_ok({"daily_alert_count": 99, "daily_alert_date": yesterday}) + + def test_under_cap_today_ok(self): + today = date.today().isoformat() + assert _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX - 1, + "daily_alert_date": today}) + + def test_at_cap_today_blocked(self): + today = date.today().isoformat() + assert not _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX, + "daily_alert_date": today}) + + def test_over_cap_today_blocked(self): + today = date.today().isoformat() + assert not _daily_ok({"daily_alert_count": PRICE_DAILY_ALERT_MAX + 5, + "daily_alert_date": today}) + + +class TestMarketplaceExtended: + def test_walmart_marketplace_seller_filtered(self): + assert _is_marketplace_thirdparty("Walmart - Greensole LLC") + + def test_walmart_bare_still_ok(self): + assert not _is_marketplace_thirdparty("Walmart") + + def test_stockx_goat_flagged(self): + assert _is_marketplace_thirdparty("StockX") + assert _is_marketplace_thirdparty("GOAT") + + +class TestCheckPriceStripsMarketplace: + """The AirPods-at-Poshmark case: cheapest listing is a resale, but check_price + should feed only real retailers to _pick_best_match.""" + + def test_poshmark_dropped_before_pick(self): + results = [ + {"title": "AirPods Pro 2", "price": 75.0, "merchant": "Poshmark", + "url": "https://example.com/1"}, + {"title": "Apple AirPods Pro 2", "price": 200.0, "merchant": "Apple", + "url": "https://example.com/2"}, + {"title": "AirPods Pro 2", "price": 100.0, "merchant": "eBay - randomseller", + "url": "https://example.com/3"}, + ] + seen = [] + + def _capture(*args, **kwargs): + seen.extend(args[1] if len(args) > 1 else kwargs.get("results", [])) + return None + + with patch("palmer.shopping._serpapi_search", return_value=results), \ + patch("palmer.shopping._pick_best_match", side_effect=_capture) as mock_pick: + check_price("AirPods Pro 2") + + mock_pick.assert_called_once() + passed = mock_pick.call_args.args[1] + merchants = {r["merchant"] for r in passed} + assert merchants == {"Apple"}, f"expected only real retailer, got {merchants}" + + +# ============================================================================ +# from test_price_alert.py +# ============================================================================ +# +# Tests for the shared price-alert drafter. +# +# shopping.py and amazon.py used to carry near-identical copies of this. These +# pin the behavior that has to stay identical across both sources, and the two +# places they legitimately differ (the price line, and whether a URL is appended). + +CURRENT = {"price": 42.0, "merchant": "Zappos", "url": "https://www.amazon.com/dp/B0PROT"} +WATCH = {"phone": "+15550001111", "target_price": None, "baseline_price": 60.0} + + +class TestContextFacts: + def test_drop_states_dollars_and_percentage(self): + # Dollars lead — the $2 materiality rule is denominated in dollars. + ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", None) + assert "Down $18.00 (about 30%)" in ctx and "$60.00" in ctx + + def test_rise_says_up_not_down(self): + # A rise must never be described as a drop; $60 -> $63.50 is +$3.50. + ctx = price_alert._context("Protein", {"price": 63.50, "merchant": "Zappos"}, + WATCH, "rise", None) + assert "Up $3.50 (about 6%)" in ctx + assert "Down" not in ctx + + def test_target_hit(self): + watch = dict(WATCH, target_price=45.0) + ctx = price_alert._context("Protein", CURRENT, watch, "target", None) + assert "at or under $45.00" in ctx + + def test_shopping_price_line_names_the_merchant(self): + ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", None) + assert "Zappos" in ctx + + def test_amazon_price_line_names_the_source(self): + ctx = price_alert._context("Protein", CURRENT, WATCH, "drop", "Amazon") + assert "Amazon price: $42.00" in ctx + + def test_missing_merchant_does_not_blow_up(self): + ctx = price_alert._context("Protein", {"price": 42.0}, WATCH, "drop", None) + assert "unknown seller" in ctx + + def test_no_baseline_no_percentage(self): + watch = dict(WATCH, baseline_price=None) + ctx = price_alert._context("Protein", CURRENT, watch, "drop", None) + assert "Down" not in ctx + + def test_rise_prompt_does_not_claim_the_watch_hit(self): + # "your price watch just hit" on a price INCREASE reads as good news. + from unittest.mock import patch, MagicMock + seen = {} + def _capture(**kw): + seen.update(kw) + m = MagicMock(); m.content = [MagicMock(text="shake went up to 63.50")] + return m + with patch("palmer.price_alert.client.messages.create", side_effect=_capture), \ + patch("palmer.price_alert._build_system", return_value="sys"): + price_alert.draft_price_alert("Protein", {"price": 63.50, "merchant": "Z"}, + WATCH, "rise") + prompt = seen["messages"][0]["content"] + assert "just hit" not in prompt + assert "went UP" in prompt + + +class TestDrafting: + def _draft(self, **kw): + with patch.object(price_alert, "_build_system", return_value="SYS"), \ + patch.object(price_alert.client.messages, "create", + return_value=llm_reply("protein's down to $42")) as create: + body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop", **kw) + return body, create + + def test_no_link_by_default(self): + body, _ = self._draft() + assert body == "protein's down to $42" + + def test_link_appended_when_given(self): + body, _ = self._draft(link=CURRENT["url"]) + assert body.endswith(CURRENT["url"]) + + def test_uses_sonnet_not_haiku(self): + """User-facing drafting belongs on Sonnet per CLAUDE.md routing.""" + _, create = self._draft() + assert create.call_args.kwargs["model"] == price_alert.SONNET_MODEL + + def test_uses_the_real_system_prompt(self): + """The whole point: price alerts sound like the Palmer they talk to.""" + _, create = self._draft() + assert create.call_args.kwargs["system"] == "SYS" + + def test_prompt_suppresses_url_when_one_is_appended(self): + _, create = self._draft(link=CURRENT["url"]) + assert "Do NOT include a URL" in create.call_args.kwargs["messages"][0]["content"] + + def test_falls_back_to_the_base_prompt_without_a_phone(self): + """A malformed row shouldn't cost the user their alert.""" + with patch.object(price_alert, "_build_system") as bs, \ + patch.object(price_alert.client.messages, "create", return_value=llm_reply("x")) as create: + price_alert.draft_price_alert("Protein", CURRENT, {"baseline_price": 60.0}, "drop") + bs.assert_not_called() + # Used to assert NO system prompt at all, which meant a failed profile + # read dropped the voice, the calibration and every NEVER rule — the + # anti-redirect one included — from a message that still went out. + sys = create.call_args.kwargs.get("system") or "" + assert "Palmer" in sys, "must still sound like Palmer" + assert "{profile_block}" not in sys, "the template must be formatted, not raw" + + +class TestFailsSafe: + def test_model_failure_falls_back_to_facts(self): + with patch.object(price_alert, "_build_system", return_value="SYS"), \ + patch.object(price_alert.client.messages, "create", side_effect=RuntimeError("boom")): + body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") + assert "42" in body and "Protein" in body + + def test_fallback_still_appends_the_link(self): + with patch.object(price_alert, "_build_system", return_value="SYS"), \ + patch.object(price_alert.client.messages, "create", side_effect=RuntimeError("boom")): + body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop", + link=CURRENT["url"], source_label="Amazon") + assert body.endswith(CURRENT["url"]) + assert "Amazon" in body + + def test_empty_model_output_falls_back(self): + with patch.object(price_alert, "_build_system", return_value="SYS"), \ + patch.object(price_alert.client.messages, "create", return_value=llm_reply(" ")): + body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") + assert body.strip(), "must never send an empty alert" + + def test_build_system_failure_does_not_lose_the_alert(self): + with patch.object(price_alert, "_build_system", side_effect=RuntimeError("db down")), \ + patch.object(price_alert.client.messages, "create", return_value=llm_reply("x")): + body = price_alert.draft_price_alert("Protein", CURRENT, WATCH, "drop") + assert body.strip() + + +class TestBothSourcesDelegate: + def test_shopping_passes_no_link(self): + from palmer import shopping + with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: + shopping._draft_alert("Protein", CURRENT, WATCH, "drop") + assert d.call_args.kwargs.get("link") is None + + def test_amazon_passes_link_and_label(self): + from palmer import amazon + with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: + amazon.draft_alert("Protein", CURRENT, WATCH, "drop") + assert d.call_args.kwargs["link"] == CURRENT["url"] + assert d.call_args.kwargs["source_label"] == "Amazon" + + +# ============================================================================ +# from test_amazon_watches.py +# ============================================================================ +# +# Tests for Amazon price-watch logic. Pure logic + mocked SerpAPI/Haiku — +# no real network or LLM calls. Run: pytest tests/test_shopping.py + +class TestExtractPrice: + def test_extracted_price_number(self): + assert amazon._extract_price({"extracted_price": 42.99}) == 42.99 + + def test_extracted_price_int(self): + assert amazon._extract_price({"extracted_price": 30}) == 30.0 + + def test_price_string_with_dollar_sign(self): + assert amazon._extract_price({"price": "$54.00"}) == 54.0 + + def test_price_string_with_comma(self): + assert amazon._extract_price({"price": "$1,299.99"}) == 1299.99 + + def test_price_string_malformed_returns_none(self): + assert amazon._extract_price({"price": "call for price"}) is None + + def test_missing_price_returns_none(self): + assert amazon._extract_price({}) is None + + def test_non_dict_returns_none(self): + assert amazon._extract_price(None) is None + assert amazon._extract_price("not a dict") is None + + def test_extracted_price_wins_over_string(self): + # Both present — the numeric field is authoritative + assert amazon._extract_price({"extracted_price": 40.0, "price": "$99.99"}) == 40.0 + + +class TestSerpapiSearch: + def _payload(self, items): + return {"organic_results": items} + + def test_missing_key_returns_empty(self): + with patch("palmer.serpapi.API_KEY", ""): + assert amazon._serpapi_search("protein") == [] + + def test_empty_query_returns_empty(self): + with patch("palmer.serpapi.API_KEY", "fake"): + assert amazon._serpapi_search("") == [] + + def test_http_failure_returns_empty(self): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=None): + assert amazon._serpapi_search("protein") == [] + + def test_skips_items_without_asin(self): + payload = self._payload([ + {"title": "no asin here", "extracted_price": 20.0}, + {"asin": "B01", "title": "has asin", "extracted_price": 25.0}, + ]) + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + out = amazon._serpapi_search("thing") + assert len(out) == 1 + assert out[0]["asin"] == "B01" + + def test_skips_items_without_price(self): + payload = self._payload([ + {"asin": "B01", "title": "priced", "extracted_price": 20.0}, + {"asin": "B02", "title": "no price"}, + ]) + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + out = amazon._serpapi_search("thing") + assert [r["asin"] for r in out] == ["B01"] + + def test_falls_back_to_dp_url_when_missing_link(self): + payload = self._payload([ + {"asin": "B0ABC123", "title": "t", "extracted_price": 10.0}, + ]) + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + out = amazon._serpapi_search("thing") + assert out[0]["url"] == "https://www.amazon.com/dp/B0ABC123" + + +class TestPickBestMatch: + def _candidates(self): + return [ + {"asin": "B01", "title": "Optimum Nutrition Gold Standard 5lb", "price": 54.0, "url": ""}, + {"asin": "B02", "title": "Optimum Nutrition Gold Standard 2lb", "price": 32.0, "url": ""}, + ] + + def test_empty_candidates_returns_none(self): + assert amazon._pick_best_match("whey", []) is None + + def test_picks_indexed_candidate(self): + with patch("palmer.amazon.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("0") + out = amazon._pick_best_match("Optimum Nutrition 5lb", self._candidates()) + assert out["asin"] == "B01" + + def test_none_reply_returns_none(self): + with patch("palmer.amazon.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("NONE") + assert amazon._pick_best_match("random", self._candidates()) is None + + def test_out_of_range_index_returns_none(self): + with patch("palmer.amazon.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("99") + assert amazon._pick_best_match("q", self._candidates()) is None + + def test_haiku_exception_returns_none(self): + with patch("palmer.amazon.client") as mock_client: + mock_client.messages.create.side_effect = RuntimeError("boom") + assert amazon._pick_best_match("q", self._candidates()) is None + + +class TestExtractAsin: + def test_empty_returns_none(self): + assert amazon._extract_asin("") is None + assert amazon._extract_asin(None) is None + + def test_plain_text_returns_none(self): + assert amazon._extract_asin("protein shakes I like") is None + + def test_bare_amazon_url_extracts(self): + assert amazon._extract_asin("https://www.amazon.com/dp/B0ABCDEFGH") == "B0ABCDEFGH" + + def test_amazon_url_with_slug_extracts(self): + url = "https://www.amazon.com/Optimum-Nutrition-Standard-Whey-Chocolate/dp/B000QSNYGI/ref=xyz" + assert amazon._extract_asin(url) == "B000QSNYGI" + + def test_gp_product_url_extracts(self): + assert amazon._extract_asin("https://www.amazon.com/gp/product/B01AABBCCD?psc=1") == "B01AABBCCD" + + def test_url_embedded_in_text_extracts(self): + assert amazon._extract_asin("check this out https://www.amazon.com/dp/B0ZZ111111 pretty good") == "B0ZZ111111" + + def test_lowercase_asin_does_not_match(self): + # Amazon ASINs are always uppercase; a lowercase 10-char string is not an ASIN. + assert amazon._extract_asin("https://www.amazon.com/dp/abcdefghij") is None + + def test_a_co_short_url_follows_redirect(self): + # Simulate a.co redirecting to the canonical amazon.com/dp/… URL + with patch("palmer.amazon._resolve_short_url", + return_value="https://www.amazon.com/dp/B0XYZ12345?ref=short"): + assert amazon._extract_asin("https://a.co/d/08q64W9B") == "B0XYZ12345" + + def test_amzn_to_short_url_follows_redirect(self): + with patch("palmer.amazon._resolve_short_url", + return_value="https://www.amazon.com/gp/product/B0AMZTOAAA"): + assert amazon._extract_asin("here: https://amzn.to/3abcXYZ") == "B0AMZTOAAA" + + def test_short_url_redirect_failure_returns_none(self): + with patch("palmer.amazon._resolve_short_url", return_value=None): + assert amazon._extract_asin("https://a.co/d/broken") is None + + def test_short_url_redirects_to_non_product_page(self): + # a.co could resolve to an amazon.com homepage / cart / list URL — + # if there's no /dp/ in the final URL, return None. + with patch("palmer.amazon._resolve_short_url", + return_value="https://www.amazon.com/hz/wishlist/ls/XYZ"): + assert amazon._extract_asin("https://a.co/d/anythg") is None + + +class TestResolveAsin: + def _search_result(self): + return [{"asin": "B0ABC", "title": "thing", "price": 42.0, "url": "https://amazon.com/dp/B0ABC"}] + + def test_no_match_returns_none(self): + with patch("palmer.amazon._extract_asin", return_value=None), \ + patch("palmer.amazon._serpapi_search", return_value=[]), \ + patch("palmer.amazon._pick_best_match", return_value=None): + assert amazon.resolve_asin("nonsense") is None + + def test_pick_returns_watchable_dict(self): + with patch("palmer.amazon._extract_asin", return_value=None), \ + patch("palmer.amazon._serpapi_search", return_value=self._search_result()), \ + patch("palmer.amazon._pick_best_match", return_value=self._search_result()[0]): + out = amazon.resolve_asin("thing") + assert out["asin"] == "B0ABC" + assert out["title"] == "thing" + assert out["price"] == 42.0 + assert out["merchant"] == "Amazon" + assert out["url"].endswith("B0ABC") + + def test_url_fast_path_skips_search(self): + """When the user pastes an Amazon URL, resolve_asin extracts the ASIN + and hits amazon_product directly — no SerpAPI search, no Haiku pick.""" + product = {"title": "Optimum Nutrition Gold Standard", "price": 54.0} + with patch("palmer.amazon._extract_asin", return_value="B000QSNYGI"), \ + patch("palmer.amazon._amazon_product", return_value=product) as amp, \ + patch("palmer.amazon._serpapi_search") as search, \ + patch("palmer.amazon._pick_best_match") as pick: + out = amazon.resolve_asin("https://www.amazon.com/dp/B000QSNYGI") + assert out == { + "asin": "B000QSNYGI", + "title": "Optimum Nutrition Gold Standard", + "price": 54.0, + "url": "https://www.amazon.com/dp/B000QSNYGI", + "merchant": "Amazon", + } + amp.assert_called_once_with("B000QSNYGI") + search.assert_not_called() # URL path skips search + pick.assert_not_called() + + def test_url_fast_path_product_lookup_fails(self): + """If the URL parses but amazon_product returns None, don't silently + fall back to searching the URL string — return None so the caller can + tell the user something went wrong with this specific listing.""" + with patch("palmer.amazon._extract_asin", return_value="B0DEADBEEF"), \ + patch("palmer.amazon._amazon_product", return_value=None), \ + patch("palmer.amazon._serpapi_search") as search: + assert amazon.resolve_asin("https://www.amazon.com/dp/B0DEADBEEF") is None + search.assert_not_called() + + +class TestCheckPrice: + """check_price hits amazon_product with the stored ASIN, not the original query.""" + + def _watch(self, **kw): + base = {"id": 1, "asin": "B0PROT", "product_name": "Protein"} + base.update(kw) + return base + + def test_missing_asin_returns_none(self): + with patch("palmer.serpapi.API_KEY", "fake"): + assert amazon.check_price(self._watch(asin=None)) is None + + def test_missing_key_returns_none(self): + with patch("palmer.serpapi.API_KEY", ""): + assert amazon.check_price(self._watch()) is None + + def test_http_failure_returns_none(self): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=None): + assert amazon.check_price(self._watch()) is None + + def test_reads_product_results_price(self): + payload = {"product_results": {"title": "Full title", "extracted_price": 42.0}} + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + out = amazon.check_price(self._watch()) + assert out["price"] == 42.0 + assert out["title"] == "Full title" + assert out["merchant"] == "Amazon" + assert out["url"] == "https://www.amazon.com/dp/B0PROT" + + def test_falls_back_to_buybox_when_product_results_priceless(self): + payload = {"product_results": {"title": "T"}, "buybox_winner": {"extracted_price": 39.99}} + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + out = amazon.check_price(self._watch()) + assert out["price"] == 39.99 + + def test_no_price_anywhere_returns_none(self): + payload = {"product_results": {"title": "T"}} + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): + assert amazon.check_price(self._watch()) is None + + def test_uses_asin_not_query(self): + """Regression: check_price MUST send the ASIN, not the product name. + Rebuilding on the phrase would drift over time as Amazon's ranking changes.""" + captured = {} + def _capture(url, timeout): + captured["url"] = url + return {"product_results": {"extracted_price": 10.0}} + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", side_effect=_capture): + amazon.check_price(self._watch(asin="B0XYZ", product_name="totally different")) + assert "asin=B0XYZ" in captured["url"] + assert "totally+different" not in captured["url"] + + +class TestDraftAlert: + def _watch(self, **kw): + base = {"target_price": None, "baseline_price": 60.0} + base.update(kw) + return base + + def _current(self, **kw): + base = {"price": 42.0, "url": "https://www.amazon.com/dp/B0PROT"} + base.update(kw) + return base + + # Drafting moved to price_alert (shared with shopping), so the model client + # to patch lives there — patching amazon.client here silently let these make + # real API calls. + def test_appends_url_even_on_draft_failure(self): + with patch("palmer.price_alert.client") as mock_client: + mock_client.messages.create.side_effect = RuntimeError("boom") + body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") + assert body.endswith("https://www.amazon.com/dp/B0PROT") + + def test_appends_url_on_success(self): + block = MagicMock() + block.text = "your protein just dropped to $42, was $60" + resp = MagicMock() + resp.content = [block] + with patch("palmer.price_alert.client") as mock_client: + mock_client.messages.create.return_value = resp + body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") + assert body.endswith("https://www.amazon.com/dp/B0PROT") + assert "$42" in body + + +class TestRunPriceWatchesDispatch: + """The critical integration point: shopping-source rows go to shopping.check_price; + amazon-source rows go to amazon.check_price. A mistake here corrupts baselines silently.""" + + def _amazon_watch(self): + return { + "id": 1, "phone": "+15551234567", "product_name": "Protein", + "source": "amazon", "asin": "B0ABC", + "target_price": None, "baseline_price": None, + "last_seen_price": None, "last_seen_url": None, "last_seen_merchant": None, + "cooldown_hours": 12, "last_alerted": None, "last_alert_summary": None, + "currency": "USD", + } + + def _shopping_watch(self): + return { + "id": 2, "phone": "+15551234567", "product_name": "Sneakers", + "source": "shopping", "asin": None, + "target_price": None, "baseline_price": None, + "last_seen_price": None, "last_seen_url": None, "last_seen_merchant": None, + "cooldown_hours": 12, "last_alerted": None, "last_alert_summary": None, + "currency": "USD", + } + + def test_amazon_row_routes_to_amazon_check_price(self): + amz = self._amazon_watch() + shp = self._shopping_watch() + current = {"price": 40.0, "title": "t", "merchant": "Amazon", + "url": "https://www.amazon.com/dp/B0ABC"} + with patch("palmer.db.get_active_price_watches", return_value=[amz, shp]), \ + patch("palmer.amazon.check_price", return_value=current) as amz_check, \ + patch("palmer.shopping.check_price", return_value=None) as shp_check, \ + patch("palmer.db.set_price_watch_baseline") as set_baseline, \ + patch("palmer.sms_util.ensure_sms", return_value=True): + shopping.run_price_watches() + # Amazon path called with the full watch dict (needs the ASIN) + amz_check.assert_called_once_with(amz) + # Shopping path called with the product name string + shp_check.assert_called_once_with(shp["product_name"]) + # Baseline seed happens for the Amazon row (baseline was None) + set_baseline.assert_called_once() + assert set_baseline.call_args[0][0] == amz["id"] + + def test_missing_source_defaults_to_shopping_path(self): + """Older rows written before the source column existed have no source + set. Dispatch must treat them as shopping, not skip or crash.""" + legacy = self._shopping_watch() + legacy.pop("source") # simulate a pre-migration row shape + with patch("palmer.db.get_active_price_watches", return_value=[legacy]), \ + patch("palmer.amazon.check_price") as amz_check, \ + patch("palmer.shopping.check_price", return_value=None) as shp_check, \ + patch("palmer.db.set_price_watch_baseline"): + shopping.run_price_watches() + amz_check.assert_not_called() + shp_check.assert_called_once_with(legacy["product_name"]) + + def test_amazon_alert_uses_amazon_draft_alert(self): + """When the alert fires on an Amazon row, the Amazon drafter (URL-inclusive) + runs — not the shopping drafter (URL-less).""" + amz = self._amazon_watch() + amz["baseline_price"] = 60.0 # already seeded; a drop should alert + current = {"price": 40.0, "title": "t", "merchant": "Amazon", + "url": "https://www.amazon.com/dp/B0ABC"} + with patch("palmer.db.get_active_price_watches", return_value=[amz]), \ + patch("palmer.amazon.check_price", return_value=current), \ + patch("palmer.amazon.draft_alert", return_value="alert body https://www.amazon.com/dp/B0ABC") as amz_draft, \ + patch("palmer.shopping._draft_alert") as shop_draft, \ + patch("palmer.sms_util.ensure_sms", return_value=True), \ + patch("palmer.db.claim_price_watch_alert", return_value=True), \ + patch("palmer.db.update_price_watch_alerted"): + shopping.run_price_watches() + amz_draft.assert_called_once() + shop_draft.assert_not_called() diff --git a/tests/test_shows.py b/tests/test_shows.py deleted file mode 100644 index ed70385..0000000 --- a/tests/test_shows.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Followed shows: episode-level tracking for series someone actually watches. - -Different in kind from the `screen` rows beside them, and the distinction is the -feature. Those are discovery — what is new to anyone, ranked by popularity, -identical for every user. These exist only because someone named the show. - -Three rules came from the spec and each has a test that would catch its -reversal: - - * a show earns a row in the WEEK its episode lands, and is silent between - seasons — not a permanent countdown; - * it lives on the page and reaches the morning TEXT only if asked, because a - weekly "new episode!!" nobody requested is the drumbeat this product keeps - having to remove; - * it displaces generic film discovery rather than adding to the row count — a - show you watch is worth more than a film picked for you. - -All offline. -""" -from datetime import date, timedelta -from unittest.mock import patch - -from palmer import morning -from palmer import opening -from palmer import shows - - -TODAY = date(2026, 8, 29) - - -def _tv(next_days=None, last_days=None, name="Furious"): - """A TMDB /tv/{id} payload with episodes at offsets from TODAY.""" - out = {"name": name, "next_episode_to_air": None, "last_episode_to_air": None} - if next_days is not None: - out["next_episode_to_air"] = { - "air_date": (TODAY + timedelta(days=next_days)).isoformat(), - "season_number": 1, "episode_number": 8, "name": "Hart Island"} - if last_days is not None: - out["last_episode_to_air"] = { - "air_date": (TODAY + timedelta(days=last_days)).isoformat(), - "season_number": 1, "episode_number": 7, "name": "Previously"} - return out - - -class TestARowOnlyInItsWeek: - def setup_method(self): - shows._clear_cache() - - def _ep(self, payload): - with patch.object(shows, "_key", return_value="k"), \ - patch.object(shows, "_get", return_value=payload): - return shows.next_episode(1, TODAY) - - def test_an_episode_landing_this_week_earns_a_row(self): - ep = self._ep(_tv(next_days=2)) - assert ep and ep["state"] == "upcoming" and ep["episode"] == 8 - - def test_one_that_just_dropped_still_counts_as_news(self): - ep = self._ep(_tv(last_days=-1)) - assert ep and ep["state"] == "dropped" - - def test_a_show_between_seasons_produces_nothing(self): - """Not a permanent countdown — the row exists for the week an episode - is in play.""" - assert self._ep(_tv()) is None - - def test_an_episode_a_month_out_is_not_this_week(self): - assert self._ep(_tv(next_days=30)) is None - - def test_last_week_is_history_not_news(self): - assert self._ep(_tv(last_days=-9)) is None - - def test_upcoming_wins_over_an_older_drop(self): - ep = self._ep(_tv(next_days=3, last_days=-1)) - assert ep["state"] == "upcoming" - - def test_two_users_watching_the_same_show_cost_one_lookup(self): - """Cached by (show, day) and therefore shared, the same way the metro - cache works for events.""" - calls = [] - - def _get(path, **kw): - calls.append(path) - return _tv(next_days=2) - - with patch.object(shows, "_key", return_value="k"), \ - patch.object(shows, "_get", side_effect=_get): - shows.next_episode(1, TODAY) - shows.next_episode(1, TODAY) - assert len(calls) == 1 - - def test_no_key_means_no_call(self): - with patch.object(shows, "_key", return_value=""), \ - patch.object(shows, "_get") as g: - assert shows.next_episode(1, TODAY) is None - g.assert_not_called() - - -class TestHowARowReads: - def setup_method(self): - shows._clear_cache() - - def _rows(self, payload): - # Cleared per call: the (show, day) cache is doing its job, so calling - # this twice in one test would otherwise return the first answer. - shows._clear_cache() - with patch.object(shows, "_key", return_value="k"), \ - patch.object(shows, "_get", return_value=payload): - return shows.episode_rows({"shows": [{"id": 1, "name": "Furious"}]}, TODAY) - - def test_the_show_is_the_title_and_the_episode_is_the_detail(self): - r = self._rows(_tv(next_days=2))[0] - assert r["title"] == "Furious" - assert r["subtitle"] == "S1E8 - Hart Island" - assert r["kind"] == "episode" - - def test_when_reads_like_a_person_said_it(self): - assert self._rows(_tv(next_days=0))[0]["when"] == "out today" - assert self._rows(_tv(next_days=1))[0]["when"] == "tomorrow" - assert self._rows(_tv(last_days=-1))[0]["when"] == "out now" - - def test_a_few_days_out_names_the_day(self): - r = self._rows(_tv(next_days=3))[0] - assert r["when"] == (TODAY + timedelta(days=3)).strftime("%A") - - def test_it_carries_a_date_so_it_expires_like_any_other_row(self): - assert self._rows(_tv(next_days=2))[0]["date"] - - def test_a_broken_lookup_drops_that_show_only(self): - prof = {"shows": [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]} - with patch.object(shows, "_key", return_value="k"), \ - patch.object(shows, "next_episode", - side_effect=[RuntimeError("tmdb down"), None]): - assert shows.episode_rows(prof, TODAY) == [] - - def test_no_followed_shows_is_not_an_error(self): - assert shows.episode_rows({}, TODAY) == [] - - -class TestEpisodesLeadAndDisplace: - ROW = {"kind": "episode", "title": "Furious", "subtitle": "S1E8", "when": "Monday", - "url": None, "source": "themoviedb.org", "date": None} - - def _snapshot(self, episodes): - from palmer.timeutil import local_today - opening._clear_caches() - today = local_today("America/Chicago") - opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] - opening._local_cache[(38.5, -90.5, today.isoformat())] = [ - {"kind": "event", "title": f"Gig {i}", "subtitle": "", "when": "", - "url": None, "source": "", "date": None} for i in range(3)] - opening._screen_cache[today.isoformat()] = [ - {"kind": "screen", "title": f"Film {i}", "subtitle": "", "when": "", - "url": None, "source": ""} for i in range(4)] - with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("palmer.shows.episode_rows", return_value=episodes): - return opening.opening_snapshot({"city": "Kirkwood, MO", - "timezone": "America/Chicago"}) - - def test_a_followed_show_leads(self): - """The only row on the page anyone asked for by name.""" - rows = self._snapshot([self.ROW]) - assert rows[0]["kind"] == "episode" - - def test_it_displaces_a_film_rather_than_lengthening_the_page(self): - with_ep = self._snapshot([self.ROW]) - without = self._snapshot([]) - assert len(with_ep) == len(without) - assert sum(r["kind"] == "screen" for r in with_ep) \ - == sum(r["kind"] == "screen" for r in without) - 1 - - def test_two_shows_take_both_film_slots(self): - rows = self._snapshot([self.ROW, dict(self.ROW, title="Reacher")]) - assert sum(r["kind"] == "screen" for r in rows) == 0 - - def test_it_is_capped(self): - rows = self._snapshot([dict(self.ROW, title=f"Show {i}") for i in range(5)]) - assert sum(r["kind"] == "episode" for r in rows) == opening.MAX_EPISODES - - def test_it_ignores_the_discovery_kinds_setting(self): - """`opening_kinds` chooses which kinds of DISCOVERY you want. A show you - named is not discovery — unfollowing is its control.""" - from palmer.timeutil import local_today - opening._clear_caches() - today = local_today("America/Chicago") - opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] - opening._local_cache[(38.5, -90.5, today.isoformat())] = [] - opening._screen_cache[today.isoformat()] = [] - with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("palmer.shows.episode_rows", return_value=[self.ROW]): - rows = opening.opening_snapshot({ - "city": "Kirkwood, MO", "timezone": "America/Chicago", - "morning_prefs": {"opening_kinds": ["local"]}}) - assert [r["kind"] for r in rows] == ["episode"] - - def test_a_broken_shows_module_never_costs_the_section(self): - from palmer.timeutil import local_today - opening._clear_caches() - today = local_today("America/Chicago") - opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] - opening._local_cache[(38.5, -90.5, today.isoformat())] = [ - {"kind": "event", "title": "Gig", "subtitle": "", "when": "", - "url": None, "source": "", "date": None}] - opening._screen_cache[today.isoformat()] = [] - with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("palmer.shows.episode_rows", side_effect=RuntimeError("boom")): - rows = opening.opening_snapshot({"city": "Kirkwood, MO", - "timezone": "America/Chicago"}) - assert [r["title"] for r in rows] == ["Gig"] - - -class TestThePageByDefaultNeverTheText: - EP = {"kind": "episode", "title": "Furious", "subtitle": "S1E8 - Hart Island", - "when": "Monday"} - GIG = {"kind": "event", "title": "Todd Rundgren", "subtitle": "The Pageant", - "when": "Tonight"} - - def _digest(self, alerts): - return morning._payload_digest({ - "city": "Kirkwood", "weather": {"high": 90, "low": 70, "description": "sunny", - "high_confident": True}, - "opening": [self.EP, self.GIG], "episode_alerts": alerts}) - - def test_a_followed_show_stays_off_the_morning_text_by_default(self): - d = self._digest(False) - assert "Furious" not in d and "Todd Rundgren" in d - - def test_it_appears_once_they_ask(self): - assert "Furious" in self._digest(True) - - def test_the_flag_rides_the_payload_not_a_profile_read(self): - """_payload_digest has no phone and must not grow one.""" - import inspect - src = inspect.getsource(morning._payload_digest) - assert "episode_alerts" in src and "get_profile" not in src - - def test_home_puts_the_flag_on_the_payload(self): - import inspect - from palmer import home - assert "episode_alerts" in inspect.getsource(home._refresh_identity) - - -class TestFollowing: - def test_the_field_is_on_the_schema(self): - """A key outside PROFILE_FIELDS is silently dropped on write.""" - from palmer.userprofile import PROFILE_FIELDS - assert "shows" in PROFILE_FIELDS - - def test_resolution_happens_on_the_write_path(self): - import inspect - from palmer import agent - src = inspect.getsource(agent.get_reply) - block = src.split('"follow_show"')[1].split("elif b.name")[0] - assert "find_shows" in block - - def test_an_unresolvable_title_asks_rather_than_guessing(self): - import inspect - from palmer import agent - src = inspect.getsource(agent.get_reply) - block = src.split('"follow_show"')[1].split("elif b.name")[0] - assert "confirm the title" in block - assert "do not send them elsewhere" in block - - def test_following_is_capped(self): - import inspect - from palmer import agent - src = inspect.getsource(agent.get_reply) - block = src.split('"follow_show"')[1].split("elif b.name")[0] - assert "FOLLOW_MAX" in block - - def test_following_expires_the_cached_section(self): - """Otherwise the show they just added is missing for up to a day.""" - import inspect - from palmer import agent - src = inspect.getsource(agent.get_reply) - block = src.split('"follow_show"')[1].split("elif b.name")[0] - assert "invalidate" in block - - -class TestAnAmbiguousTitleIsAskedAboutNotGuessed: - """sports.find_teams returns a LIST because "Cardinals" is two teams, and - its comment says naming a show is not ambiguous the same way. That is true - of Reacher and false of The Office, Shameless, Skins and Ghosts. Taking - results[0] meant Palmer confirmed a pick as though the user had made it, - and the mistake stays silent until the episode rows are for a series they - don't watch.""" - - US = {"id": 1, "name": "The Office", "year": "2005", "country": "US"} - GB = {"id": 2, "name": "The Office", "year": "2001", "country": "GB"} - OTHER = {"id": 3, "name": "The Office Deep Dive", "year": "2022", "country": "US"} - - def test_two_series_with_one_title_is_a_question(self): - assert shows.ambiguous_shows([self.US, self.GB]) == [self.US, self.GB] - - def test_a_different_title_is_not(self): - """"Reacher" also matching "Reacher: Behind the Scenes" is the search - working, not two things the user might have meant.""" - assert shows.ambiguous_shows([self.US, self.OTHER]) == [] - - def test_one_match_is_not(self): - assert shows.ambiguous_shows([self.US]) == [] - - def test_the_description_separates_them_for_a_person(self): - assert shows.describe_show(self.GB) == "The Office (2001, GB)" - - def test_the_dispatch_refuses_to_pick(self): - import inspect - from palmer import agent - block = inspect.getsource(agent.get_reply).split('"follow_show"')[1] \ - .split("elif b.name")[0] - assert "ambiguous_shows" in block - assert "Do NOT pick one yourself" in block diff --git a/tests/test_sms.py b/tests/test_sms.py new file mode 100644 index 0000000..2b9fc4c --- /dev/null +++ b/tests/test_sms.py @@ -0,0 +1,812 @@ +"""Inbound and outbound SMS: chunking, links, reactions. + +Merged from test_sms_chunking.py, test_link_integrity.py, test_tapback.py, test_reaction_interpret.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import pytest +from unittest.mock import patch, MagicMock +from palmer.smstext import _sms_clean +from palmer.sms_util import _split_for_sms, send_sms +from palmer import sms_util, smstext, tapback, main +from tests.helpers import llm_reply + + +# ============================================================================ +# from test_sms_chunking.py +# ============================================================================ + +class TestSmsCleanNoTruncation: + def test_short_text_unchanged(self): + assert _sms_clean("hello there") == "hello there" + + def test_long_text_not_truncated(self): + text = "This is a sentence. " * 100 # ~2100 chars + assert len(_sms_clean(text)) == len(text.strip()) + + def test_strips_bullets_and_markdown(self): + text = "- first point\n* second point\n**bold**\n# Header" + cleaned = _sms_clean(text) + assert "-" not in cleaned.split("\n")[0][:1] + assert "*" not in cleaned + assert "#" not in cleaned + + +class TestSplitForSms: + def test_short_text_single_part(self): + assert _split_for_sms("short message") == ["short message"] + + def test_paragraph_split_preferred(self): + text = "First topic here.\n\nSecond topic here." + ("x" * 900) + parts = _split_for_sms(text) + assert len(parts) == 2 + assert parts[0] == "First topic here." + + def test_hard_chunk_fallback_when_no_breaks(self): + text = "a" * 2000 # no paragraph breaks at all + parts = _split_for_sms(text, max_chars=900) + assert len(parts) == 3 + assert all(len(p) <= 900 for p in parts) + assert "".join(parts) == text + + def test_exactly_at_limit_single_part(self): + text = "a" * 900 + assert _split_for_sms(text, max_chars=900) == [text] + + +class TestSendSmsChunking: + def test_long_body_sent_as_multiple_messages(self, monkeypatch): + monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") + long_body = ("Weather update here.\n\n" + "Traffic is normal today.\n\n" + "News: " + "x" * 900) + with patch("palmer.sms_util._twilio") as mock_twilio: + mock_twilio.messages.create.return_value = MagicMock() + result = send_sms("+15551234567", long_body) + assert result is True + assert mock_twilio.messages.create.call_count == 3 + bodies = [c.kwargs["body"] for c in mock_twilio.messages.create.call_args_list] + assert bodies[0] == "Weather update here." + assert bodies[1] == "Traffic is normal today." + assert "News:" in bodies[2] + + def test_short_body_sent_as_one_message(self, monkeypatch): + monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") + with patch("palmer.sms_util._twilio") as mock_twilio: + mock_twilio.messages.create.return_value = MagicMock() + result = send_sms("+15551234567", "quick note") + assert result is True + assert mock_twilio.messages.create.call_count == 1 + assert mock_twilio.messages.create.call_args.kwargs["body"] == "quick note" + + def test_no_content_lost_across_chunks(self, monkeypatch): + monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") + long_body = "\n\n".join(f"Topic {i}: some detail about it." for i in range(20)) + with patch("palmer.sms_util._twilio") as mock_twilio: + mock_twilio.messages.create.return_value = MagicMock() + send_sms("+15551234567", long_body) + bodies = [c.kwargs["body"] for c in mock_twilio.messages.create.call_args_list] + assert "\n\n".join(bodies) == long_body + + +# ============================================================================ +# from test_link_integrity.py +# ============================================================================ +# +# A URL must survive the SMS pipeline byte for byte, or not be sent at all. +# +# "Bad links" turned out to be three separate mechanical defects, none of them in +# the code that chooses a link: +# +# - the markdown scrub rewrote `[text](url)` to `text`, DELETING the target; +# - the ASCII fold dropped non-ASCII bytes out of the middle of a path, leaving +# a URL that looks right and resolves to nothing; +# - three truncating paths (`shorten_message`, the `body[:320]` fallback, the +# hard chunker) all cut at a fixed offset, which lands mid-URL on exactly the +# messages most likely to carry one. +# +# And the /sms-status retry fed a live message back through the first and third of +# those. morning.py opted out of that callback by hand; nothing else did. + +URL = "https://palmer.example/h/AbC123_xyz" + + +class TestMarkdownLinksKeepTheirTarget: + def test_the_url_survives(self): + out = smstext._sms_clean(f"here you go: [your page]({URL})") + assert URL in out + + def test_the_label_survives_too(self): + out = smstext._sms_clean(f"here you go: [your page]({URL})") + assert "your page" in out + + def test_a_non_http_target_is_still_dropped(self): + # `[x](#)` is markup, not a link worth texting. + out = smstext._sms_clean("see [the notes](#anchor)") + assert "the notes" in out + assert "#anchor" not in out + + +class TestTheAsciiFoldDoesNotCorruptUrls: + def test_a_plain_url_is_untouched(self): + assert smstext._sms_clean(f"tap it {URL}").endswith(URL) + + def test_a_non_ascii_path_is_percent_encoded_not_truncated(self): + out = smstext._sms_clean("https://example.com/café/menu") + # The old fold silently deleted the accented byte, producing + # https://example.com/caf/menu — a valid-looking URL to nowhere. + assert "/caf/menu" not in out + assert "caf%C3%A9" in out + + def test_query_strings_survive(self): + u = "https://example.com/x?a=1&b=2#frag" + assert u in smstext._sms_clean(f"here {u}") + + def test_emoji_beside_a_url_is_still_stripped(self): + out = smstext._sms_clean(f"\U0001F600 {URL}") + assert URL in out + assert "\U0001F600" not in out + + +class TestTruncationNeverCutsAUrl: + def test_a_url_is_kept_whole_or_dropped(self): + text = "x" * 300 + " " + URL + out = smstext.truncate_preserving_urls(text, 320) + assert URL in out or URL[:20] not in out + + def test_a_url_alone_is_returned_when_it_cannot_fit_with_prose(self): + out = smstext.truncate_preserving_urls(URL, 10) + assert out == URL + + def test_short_text_is_untouched(self): + assert smstext.truncate_preserving_urls("hi", 320) == "hi" + + def test_no_partial_scheme_ever_ships(self): + for n in range(1, 60): + out = smstext.truncate_preserving_urls("a " + URL, n) + assert "http" not in out or URL in out + + +class TestChunkingKeepsUrlsIntact: + def test_a_url_is_not_split_across_parts(self): + body = ("word " * 400) + URL + parts = sms_util._split_for_sms(body, max_chars=900) + assert any(URL in p for p in parts), parts + + def test_nothing_is_lost(self): + body = ("word " * 400) + URL + joined = " ".join(sms_util._split_for_sms(body, max_chars=900)) + assert joined.count("word") == 400 + + def test_short_text_is_one_part(self): + assert sms_util._split_for_sms("hi") == ["hi"] + + +class TestTheStatusCallbackIsSuppressedForLinks: + """The retry at /sms-status reruns shorten_message on the original body, so + a message carrying a link must not opt into it.""" + + def _sent(self, monkeypatch, body): + seen = {} + + class _Msgs: + def create(self, **kw): + seen.update(kw) + + # send_sms reads the from-number from the environment at call time, so + # without this the suite only passes on a machine holding live Twilio + # credentials — a trap for CI and for anyone with a partial .env. + monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") + monkeypatch.setattr(sms_util, "_twilio", type("T", (), {"messages": _Msgs()})()) + monkeypatch.setattr(sms_util, "_STATUS_CALLBACK_URL", "https://app/sms-status") + sms_util.send_sms("+15550001111", body) + return seen + + def test_a_body_with_a_url_opts_out(self, monkeypatch): + assert "status_callback" not in self._sent(monkeypatch, f"morning - 72 today {URL}") + + def test_a_body_without_a_url_keeps_it(self, monkeypatch): + assert "status_callback" in self._sent(monkeypatch, "morning - 72 today") + + +class TestShortenMessageKeepsLinks: + def test_the_link_survives_a_failed_model_call(self, monkeypatch): + def _boom(*a, **k): + raise RuntimeError("no network") + monkeypatch.setattr(smstext.client.messages, "create", _boom) + out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) + assert URL in out + assert len(out) <= 320 + + def test_the_link_survives_a_model_that_drops_it(self, monkeypatch): + # Haiku never sees the URL now, so it cannot lose it. + class _R: + content = [type("B", (), {"text": "short version, no link here"})()] + monkeypatch.setattr(smstext.client.messages, "create", lambda *a, **k: _R()) + out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) + assert URL in out + + def test_the_url_is_last(self, monkeypatch): + class _R: + content = [type("B", (), {"text": "short version"})()] + monkeypatch.setattr(smstext.client.messages, "create", lambda *a, **k: _R()) + out = smstext.shorten_message("blah " * 200 + URL, max_chars=320) + assert out.endswith(URL) + + +class TestTheFlightRouteIsAscii: + def test_no_glyph_is_deleted_by_the_fold(self): + from palmer import flightwatch + route = flightwatch._route( + {"origin": "LAX", "destination": "MXP", "outbound_date": "2026-09-10"}) + assert "LAX to MXP" in route + # The bug: an unmapped arrow was dropped, leaving "LAX MXP". + assert smstext._sms_clean(route) == route + assert " " not in smstext._sms_clean(route) + + def test_the_arrow_is_mapped_for_any_other_caller(self): + assert smstext._sms_clean("LAX → MXP") == "LAX -> MXP" + + +# ============================================================================ +# from test_tapback.py +# ============================================================================ +# +# Tests for inbound reaction handling. +# +# The core requirement is negative — Palmer must send NOTHING when someone +# reacts — so the handler tests assert on calls that must not happen. +# +# main.py calls _scheduler.start() at import, and send_due_reminders runs every +# minute and sends real SMS. Neutralize it before importing main; production code +# stays untouched. + +class TestAppleTapbacks: + @pytest.mark.parametrize("body,kind,sentiment", [ + ('Liked "The audacity of it."', "liked", "positive"), + ('Loved "Peno on Clayton."', "loved", "positive"), + ('Laughed at "Airport beer or airport spiral."', "laughed", "positive"), + ('Emphasized "Get the short rib."', "emphasized", "positive"), + ('Disliked "Every six months, same speech."', "disliked", "negative"), + ('Questioned "It\'s Wednesday."', "questioned", "neutral"), + ]) + def test_all_six_parse(self, body, kind, sentiment): + r = tapback.parse_reaction(body) + assert r is not None, f"{body!r} should parse as a reaction" + assert r["kind"] == kind + assert r["sentiment"] == sentiment + + def test_quoted_text_is_captured(self): + r = tapback.parse_reaction('Liked "The audacity of it. Every single week."') + assert r["quoted"] == "The audacity of it. Every single week." + + def test_curly_quotes(self): + r = tapback.parse_reaction('Liked “the audacity of it”') + assert r is not None and r["quoted"] == "the audacity of it" + + def test_truncated_original_keeps_text(self): + r = tapback.parse_reaction('Liked "this is a long message that got cut off…"') + assert r is not None + assert not r["quoted"].endswith("…") + + def test_multiline_quote(self): + r = tapback.parse_reaction('Liked "line one\nline two"') + assert r is not None and "line two" in r["quoted"] + + def test_long_quote_is_truncated(self): + r = tapback.parse_reaction('Liked "' + "x" * 500 + '"') + assert len(r["quoted"]) <= tapback._QUOTE_TRUNCATE + + +class TestEmojiReactions: + def test_ios18_reacted_form(self): + r = tapback.parse_reaction('Reacted \U0001f602 to "that traffic take"') + assert r["kind"] == "emoji" + assert r["sentiment"] == "positive" + assert r["quoted"] == "that traffic take" + + def test_google_messages_form(self): + r = tapback.parse_reaction('\U0001f44d to "see you then"') + assert r["kind"] == "emoji" and r["sentiment"] == "positive" + + def test_bare_emoji(self): + r = tapback.parse_reaction("\U0001f44d") + assert r["kind"] == "emoji" and r["quoted"] == "" + + def test_bare_negative_emoji(self): + assert tapback.parse_reaction("\U0001f44e")["sentiment"] == "negative" + + def test_multiple_emoji_only(self): + assert tapback.parse_reaction("\U0001f525\U0001f4af") is not None + + def test_emoji_with_variation_selector(self): + assert tapback.parse_reaction("❤️") is not None + + +class TestNotReactions: + """False positives are the expensive failure — a real message met with silence.""" + + @pytest.mark.parametrize("body", [ + "Liked it", # no quoted original + "I liked that restaurant you mentioned", # lowercase, mid-sentence + "Loved the game last night", + 'He said "hello" to me', + "Disliked by everyone apparently", + "what's the weather", + "\U0001f44d sounds good to me", # emoji plus real content + "thanks \U0001f602", + "", + " ", + ]) + def test_normal_messages_are_not_reactions(self, body): + assert tapback.parse_reaction(body) is None, f"{body!r} wrongly parsed as a reaction" + + def test_none_body(self): + assert tapback.parse_reaction(None) is None + + def test_is_emoji_only_rejects_empty(self): + assert not tapback.is_emoji_only("") + assert not tapback.is_emoji_only(" ") + + +class TestRecording: + def test_appends_and_caps(self): + stored = {} + + def _upsert(phone, updates): + stored.update(updates) + + with patch.object(tapback, "get_profile", side_effect=lambda p: dict(stored)), \ + patch.object(tapback, "upsert_profile", side_effect=_upsert): + for i in range(tapback.MAX_STORED_REACTIONS + 5): + tapback.record_reaction("+15550001111", { + "kind": "liked", "sentiment": "positive", + "quoted": f"msg {i}", "emoji": "", + }) + + log = stored["reactions"] + assert len(log) == tapback.MAX_STORED_REACTIONS, "rolling log must be capped" + assert log[-1]["quoted"] == f"msg {tapback.MAX_STORED_REACTIONS + 4}", "keeps newest" + + def test_db_failure_is_swallowed(self): + """A bookkeeping failure must never bubble up and trigger a reply.""" + with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): + tapback.record_reaction("+15550001111", { + "kind": "liked", "sentiment": "positive", "quoted": "x", "emoji": "", + }) + + +class TestReactionBlock: + def test_empty_without_reactions(self): + assert tapback.reaction_block({}) == "" + assert tapback.reaction_block({"reactions": []}) == "" + assert tapback.reaction_block(None) == "" + + def test_renders_recent_reactions(self): + block = tapback.reaction_block({"reactions": [ + {"kind": "liked", "sentiment": "positive", "quoted": "the audacity of it", "emoji": ""}, + {"kind": "disliked", "sentiment": "negative", "quoted": "long all-hands take", "emoji": ""}, + ]}) + assert "the audacity of it" in block + assert "long all-hands take" in block + + def test_surfaced_by_build_system(self): + from palmer import agent + profile = {"name": "Mike", "reactions": [ + {"kind": "liked", "sentiment": "positive", "quoted": "the audacity of it", "emoji": ""}, + ]} + with patch.object(agent, "get_profile", return_value=profile), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + out = agent._build_system("+15550001111") + assert "HOW THEY'VE REACTED" in out + assert "the audacity of it" in out + + +class TestHandlerSendsNothing: + """The actual requirement: a reaction produces no outbound message at all.""" + + def _run(self, body, media_url=None): + with patch.object(main, "ensure_sms") as ensure, \ + patch.object(main, "send_sms") as send, \ + patch.object(main, "get_reply") as get_reply, \ + patch.object(main, "save_message"), \ + patch.object(main, "get_history", return_value=[]), \ + patch.object(main, "get_profile", return_value={"intro_sent": True}), \ + patch.object(main, "upsert_profile") as upsert, \ + patch.object(main, "record_reaction") as record: + main._handle_sms("+15550001111", body, media_url) + return ensure, send, get_reply, record, upsert + + def test_tapback_sends_nothing(self): + ensure, send, get_reply, record, _ = self._run('Liked "the audacity of it"') + ensure.assert_not_called() + send.assert_not_called() + get_reply.assert_not_called() + record.assert_called_once() + + def test_no_fallback_sms_on_reaction(self): + """_handle_sms fires FALLBACK_SMS when the inner call returns falsy.""" + ensure, _, _, _, _ = self._run("\U0001f44d") + ensure.assert_not_called() + + def test_reaction_does_not_mark_intro_sent(self): + _, _, _, _, upsert = self._run('Liked "hi"') + upsert.assert_not_called() + + def test_normal_message_still_replies(self): + with patch.object(main, "ensure_sms", return_value=True) as ensure, \ + patch.object(main, "get_reply", return_value=("sure thing", None)) as get_reply, \ + patch.object(main, "save_message"), \ + patch.object(main, "save_assistant_turn"), \ + patch.object(main, "get_history", return_value=[]), \ + patch.object(main, "get_profile", return_value={"intro_sent": True}), \ + patch.object(main, "upsert_profile"): + main._handle_sms("+15550001111", "what's the weather", None) + get_reply.assert_called_once() + ensure.assert_called_once() + + def test_photo_is_never_a_reaction(self): + """An emoji caption on an MMS must not swallow the photo.""" + _, _, get_reply, record, _ = self._run("\U0001f44d", media_url="http://x/img.jpg") + record.assert_not_called() + get_reply.assert_called_once() + + +# ============================================================================ +# from test_reaction_interpret.py +# ============================================================================ +# +# Tests for reaction interpretation and the reply/silence decision. +# +# Haiku is mocked throughout — these test the plumbing, the validation, and above +# all the failure behavior. The rule that matters: anything that goes wrong must +# land on silence, never on an unwanted text. +# +# Live model behavior (does Haiku actually call a thumbs-up on a question an +# "answer"?) is verified separately against the real API, not here. + +LIKED = {"kind": "liked", "sentiment": "positive", "quoted": "want me to add that?", "emoji": ""} +THUMBS = {"kind": "emoji", "sentiment": "positive", "quoted": "", "emoji": "\U0001f44d"} + + +class TestInterpretVerdicts: + @pytest.mark.parametrize("function,needs_reply", [ + ("answer", True), + ("closer", False), + ("applause", False), + ("objection", False), + ("emotional", False), + ]) + def test_only_answer_needs_reply(self, function, needs_reply): + payload = f'{{"function": "{function}", "sentiment": "positive", "about": "mornings"}}' + with patch.object(tapback.client.messages, "create", return_value=llm_reply(payload)): + v = tapback.interpret_reaction(THUMBS, "want me to add that to your morning?", {}) + assert v["function"] == function + assert v["needs_reply"] is needs_reply + + def test_sentiment_is_taken_from_the_model_not_the_emoji_map(self): + """A skull is 'neutral' to the static map; in context it can be praise.""" + skull = {"kind": "emoji", "sentiment": "neutral", "quoted": "", "emoji": "\U0001f480"} + payload = '{"function": "applause", "sentiment": "positive", "about": "the joke"}' + with patch.object(tapback.client.messages, "create", return_value=llm_reply(payload)): + v = tapback.interpret_reaction(skull, "They always pick the day before the weekend.", {}) + assert v["sentiment"] == "positive" + + def test_about_is_captured_and_truncated(self): + payload = '{"function": "objection", "sentiment": "negative", "about": "' + "x" * 200 + '"}' + with patch.object(tapback.client.messages, "create", return_value=llm_reply(payload)): + v = tapback.interpret_reaction(LIKED, "bitcoin is up 12%", {}) + assert len(v["about"]) <= 40 + + +class TestInterpretFailsSafe: + """Every failure path must produce silence.""" + + def test_api_exception(self): + with patch.object(tapback.client.messages, "create", side_effect=RuntimeError("boom")): + v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) + assert v["needs_reply"] is False and v["function"] == "closer" + + def test_unparseable_response(self): + with patch.object(tapback.client.messages, "create", return_value=llm_reply("not json at all")): + v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) + assert v["needs_reply"] is False + + def test_invalid_function_value(self): + with patch.object(tapback.client.messages, "create", + return_value=llm_reply('{"function": "vibes", "sentiment": "positive"}')): + v = tapback.interpret_reaction(THUMBS, "want me to add that?", {}) + assert v["function"] == "closer" and v["needs_reply"] is False + + def test_invalid_sentiment_falls_back_to_parsed(self): + with patch.object(tapback.client.messages, "create", + return_value=llm_reply('{"function": "closer", "sentiment": "spicy"}')): + v = tapback.interpret_reaction(THUMBS, "hi", {}) + assert v["sentiment"] in ("positive", "negative", "neutral") + + def test_no_last_assistant_skips_the_model_entirely(self): + """Nothing to answer means no question was asked — don't pay for a call.""" + with patch.object(tapback.client.messages, "create") as create: + v = tapback.interpret_reaction(THUMBS, "", {}) + create.assert_not_called() + assert v["needs_reply"] is False + + def test_empty_reaction(self): + assert tapback.interpret_reaction({}, "hi", {})["needs_reply"] is False + + +class TestConsolidation: + def _profile(self, n): + return {"reactions": [ + {"kind": "liked", "sentiment": "positive", "quoted": f"m{i}", "about": ""} + for i in range(n) + ]} + + def test_does_not_fire_below_threshold(self): + with patch.object(tapback, "get_profile", + return_value=self._profile(tapback.CONSOLIDATE_EVERY - 1)), \ + patch.object(tapback.client.messages, "create") as create, \ + patch.object(tapback, "upsert_profile") as upsert: + tapback.maybe_consolidate("+15550001111") + create.assert_not_called() + upsert.assert_not_called() + + def test_fires_at_threshold_and_updates_style(self): + stored = {} + with patch.object(tapback, "get_profile", return_value=self._profile(tapback.CONSOLIDATE_EVERY)), \ + patch.object(tapback.client.messages, "create", + return_value=llm_reply('{"communication_style": "likes the dry stuff"}')), \ + patch.object(tapback, "upsert_profile", side_effect=lambda p, u: stored.update(u)): + tapback.maybe_consolidate("+15550001111") + assert stored["communication_style"] == "likes the dry stuff" + assert stored["reactions_folded_count"] == tapback.CONSOLIDATE_EVERY + + def test_does_not_refire_until_another_batch(self): + prof = self._profile(tapback.CONSOLIDATE_EVERY) + prof["reactions_folded_count"] = tapback.CONSOLIDATE_EVERY + with patch.object(tapback, "get_profile", return_value=prof), \ + patch.object(tapback.client.messages, "create") as create: + tapback.maybe_consolidate("+15550001111") + create.assert_not_called() + + def test_failure_is_swallowed(self): + with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): + tapback.maybe_consolidate("+15550001111") + + +class TestHandlerRouting: + """End-to-end through _handle_sms with the verdict controlled.""" + + def _run(self, body, verdict, last_assistant="want me to add that to your morning?"): + history = [{"role": "assistant", "content": last_assistant}] if last_assistant else [] + with patch.object(main, "ensure_sms", return_value=True) as ensure, \ + patch.object(main, "get_reply", return_value=("done, added", None)) as get_reply, \ + patch.object(main, "interpret_reaction", return_value=verdict), \ + patch.object(main, "record_reaction") as record, \ + patch.object(main, "learn_from_reactions") as consolidate, \ + patch.object(main, "save_message"), \ + patch.object(main, "save_assistant_turn"), \ + patch.object(main, "get_history", return_value=history), \ + patch.object(main, "get_profile", return_value={"intro_sent": True}), \ + patch.object(main, "upsert_profile"): + main._handle_sms("+15550001111", body, None) + return ensure, get_reply, record, consolidate + + def test_closer_stays_silent(self): + ensure, get_reply, record, consolidate = self._run( + 'Liked "the audacity of it"', + {"function": "closer", "sentiment": "positive", "about": "", "needs_reply": False}, + ) + ensure.assert_not_called() + get_reply.assert_not_called() + record.assert_called_once() + consolidate.assert_called_once() + + def test_answer_gets_exactly_one_reply(self): + ensure, get_reply, record, _ = self._run( + "\U0001f44d", + {"function": "answer", "sentiment": "positive", "about": "mornings", "needs_reply": True}, + ) + get_reply.assert_called_once() + ensure.assert_called_once() + record.assert_called_once() + + def test_answer_is_relabelled_for_the_model(self): + """Palmer must see 'this is their answer', not a bare emoji.""" + with patch.object(main, "ensure_sms", return_value=True), \ + patch.object(main, "get_reply", return_value=("done", None)) as get_reply, \ + patch.object(main, "interpret_reaction", + return_value={"function": "answer", "sentiment": "positive", + "about": "", "needs_reply": True}), \ + patch.object(main, "record_reaction"), \ + patch.object(main, "learn_from_reactions"), \ + patch.object(main, "save_message"), \ + patch.object(main, "save_assistant_turn"), \ + patch.object(main, "get_history", + return_value=[{"role": "assistant", "content": "want me to add that?"}]), \ + patch.object(main, "get_profile", return_value={"intro_sent": True}), \ + patch.object(main, "upsert_profile"): + main._handle_sms("+15550001111", "\U0001f44d", None) + sent = get_reply.call_args.kwargs["message"] + assert "this is their answer" in sent + assert "want me to add that?" in sent + + def test_objection_stays_silent(self): + ensure, get_reply, _, _ = self._run( + 'Disliked "bitcoin is up 12%"', + {"function": "objection", "sentiment": "negative", "about": "bitcoin", "needs_reply": False}, + ) + ensure.assert_not_called() + get_reply.assert_not_called() + + +def _neg(topic, n): + return [{"kind": "disliked", "sentiment": "negative", "function": "objection", + "about": topic, "quoted": ""} for _ in range(n)] + + +class TestPacingFactor: + def test_normal_with_no_reactions(self): + assert tapback.pacing_factor({}) == 1.0 + assert tapback.pacing_factor({"reactions": []}) == 1.0 + + def test_positives_do_not_slow_palmer_down(self): + prof = {"reactions": [{"kind": "liked", "sentiment": "positive", "function": "applause"}] * 6} + assert tapback.pacing_factor(prof) == 1.0 + + def test_negatives_increase_the_factor(self): + assert tapback.pacing_factor({"reactions": _neg("crypto", 2)}) > 1.0 + + def test_capped(self): + assert tapback.pacing_factor({"reactions": _neg("crypto", 50)}) == tapback.MAX_PACING_FACTOR + + def test_decays_as_positives_push_negatives_out(self): + """The rolling log is the decay mechanism — no reset job needed.""" + heavy = tapback.pacing_factor({"reactions": _neg("crypto", 4)}) + recovered = tapback.pacing_factor({"reactions": _neg("crypto", 1)}) + assert recovered < heavy + + def test_followup_gap_stretches(self): + from palmer import followup + from datetime import datetime, timedelta + base = {"morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": ["job offer"]} + short = followup.GAP_DAYS - 1 + recent = (datetime.now() - timedelta(days=short)).date().isoformat() + with patch.object(followup, "_local_now", + return_value=datetime.now().replace(hour=15)): + calm = dict(base, followup_sent_date=recent, reactions=[]) + noisy = dict(base, followup_sent_date=recent, reactions=_neg("checkins", 4)) + assert followup._should_send_followup(calm) is False + assert followup._should_send_followup(noisy) is False + + due = (datetime.now() - timedelta(days=followup.GAP_DAYS + 1)).date().isoformat() + with patch.object(followup, "_local_now", + return_value=datetime.now().replace(hour=15)): + calm = dict(base, followup_sent_date=due, reactions=[]) + noisy = dict(base, followup_sent_date=due, reactions=_neg("checkins", 4)) + assert followup._should_send_followup(calm) is True, "normal user is due" + assert followup._should_send_followup(noisy) is False, "backed-off user is not" + + +class TestPreferenceLearning: + def _run(self, log, existing_prefs=None, topics=("crypto", "sports", "weather")): + stored = {} + prof = {"reactions": log, "morning_topics": list(topics)} + if existing_prefs is not None: + prof["morning_prefs"] = existing_prefs + with patch.object(tapback, "get_profile", return_value=prof), \ + patch.object(tapback, "upsert_profile", side_effect=lambda p, u: stored.update(u)): + tapback.maybe_learn_preferences("+15550001111") + return stored + + def test_one_dislike_changes_nothing(self): + assert self._run(_neg("crypto", 1)) == {} + + def test_two_dislikes_change_nothing(self): + assert self._run(_neg("crypto", 2)) == {} + + def test_threshold_adds_to_avoid(self): + stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID)) + assert "crypto" in stored["morning_prefs"]["avoid"] + + def test_sets_a_notice_so_it_is_not_silent(self): + stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID)) + assert stored["pending_preference_notice"] == "crypto" + + def test_preserves_existing_avoid_entries(self): + stored = self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), + existing_prefs={"avoid": ["sports"]}) + assert set(stored["morning_prefs"]["avoid"]) == {"sports", "crypto"} + + def test_does_not_duplicate(self): + assert self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), + existing_prefs={"avoid": ["crypto"]}) == {} + + def test_spread_across_topics_does_not_trigger(self): + log = _neg("crypto", 1) + _neg("sports", 1) + _neg("weather", 1) + assert self._run(log) == {} + + def test_blank_topic_ignored(self): + assert self._run(_neg("", 5)) == {} + + def test_topic_not_in_the_briefing_is_ignored(self): + """Only things Palmer actually sends can be dropped from what he sends.""" + assert self._run(_neg("someone's dog", tapback.NEGATIVE_STREAK_FOR_AVOID)) == {} + + def test_no_briefing_topics_means_nothing_to_drop(self): + assert self._run(_neg("crypto", tapback.NEGATIVE_STREAK_FOR_AVOID), topics=()) == {} + + def test_matching_is_case_insensitive(self): + stored = self._run(_neg("CRYPTO", tapback.NEGATIVE_STREAK_FOR_AVOID)) + assert "crypto" in [a.lower() for a in stored["morning_prefs"]["avoid"]] + + def test_stores_the_original_topic_casing(self): + """morning.py matches against morning_topics, so casing must round-trip.""" + stored = self._run(_neg("bitcoin", tapback.NEGATIVE_STREAK_FOR_AVOID), + topics=("Bitcoin",)) + assert "Bitcoin" in stored["morning_prefs"]["avoid"] + + def test_failure_is_swallowed(self): + with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): + tapback.maybe_learn_preferences("+15550001111") + + +class TestNoticeSurfacedAndCleared: + def test_notice_appears_in_prompt(self): + from palmer import agent + with patch.object(agent, "get_profile", + return_value={"pending_preference_notice": "crypto"}), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + out = agent._build_system("+15550001111") + assert "crypto" in out + assert "thumbs-downed" in out + + def test_notice_absent_when_unset(self): + from palmer import agent + with patch.object(agent, "get_profile", return_value={"name": "Mike"}), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + out = agent._build_system("+15550001111") + assert "thumbs-downed" not in out + + def test_notice_is_cleared_after_being_shown(self): + """Otherwise Palmer mentions the dropped topic every single turn.""" + from palmer import agent + stored = {} + with patch.object(agent, "_update_profile"), \ + patch.object(agent, "_consolidate_history"), \ + patch.object(agent, "upsert_profile", side_effect=lambda p, u: stored.update(u)), \ + patch.object(agent, "get_profile", return_value={}): + agent._profile_and_consolidate("+15550001111", "ok", "sure", None, "crypto") + assert stored["pending_preference_notice"] is None + + +class TestWatchCapLookupIsPerUser: + """run_watches covers every watch for every user and get_profile opens a + fresh DB connection per call — the cap lookup must not be inside the loop.""" + + def test_profile_read_once_per_user_not_per_watch(self): + from palmer import watches + rows = [{"phone": "+1555000111" + str(i % 2), "id": i, "last_alerted": None, + "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, + "queries": "[]", "description": "x", "genre": None, "story_state": None} + for i in range(6)] # 6 watches spread across 2 users + + with patch.object(watches, "get_active_watches", return_value=rows), \ + patch.object(watches, "get_profile", return_value={}) as gp, \ + patch.object(watches, "_search_raw", return_value=[]): + watches.run_watches() + + assert gp.call_count == 2, f"expected 1 profile read per user, got {gp.call_count}" + + def test_profile_failure_falls_back_to_default_cap(self): + from palmer import watches + rows = [{"phone": "+15550001111", "id": 1, "last_alerted": None, + "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, + "queries": "[]", "description": "x", "genre": None, "story_state": None}] + with patch.object(watches, "get_active_watches", return_value=rows), \ + patch.object(watches, "get_profile", side_effect=RuntimeError("db down")), \ + patch.object(watches, "_search_raw", return_value=[]): + watches.run_watches() # must not raise diff --git a/tests/test_sms_chunking.py b/tests/test_sms_chunking.py deleted file mode 100644 index e647f2f..0000000 --- a/tests/test_sms_chunking.py +++ /dev/null @@ -1,80 +0,0 @@ -from dotenv import load_dotenv -load_dotenv() - -from unittest.mock import patch, MagicMock - -from palmer.smstext import _sms_clean -from palmer.sms_util import _split_for_sms, send_sms - - -class TestSmsCleanNoTruncation: - def test_short_text_unchanged(self): - assert _sms_clean("hello there") == "hello there" - - def test_long_text_not_truncated(self): - text = "This is a sentence. " * 100 # ~2100 chars - assert len(_sms_clean(text)) == len(text.strip()) - - def test_strips_bullets_and_markdown(self): - text = "- first point\n* second point\n**bold**\n# Header" - cleaned = _sms_clean(text) - assert "-" not in cleaned.split("\n")[0][:1] - assert "*" not in cleaned - assert "#" not in cleaned - - -class TestSplitForSms: - def test_short_text_single_part(self): - assert _split_for_sms("short message") == ["short message"] - - def test_paragraph_split_preferred(self): - text = "First topic here.\n\nSecond topic here." + ("x" * 900) - parts = _split_for_sms(text) - assert len(parts) == 2 - assert parts[0] == "First topic here." - - def test_hard_chunk_fallback_when_no_breaks(self): - text = "a" * 2000 # no paragraph breaks at all - parts = _split_for_sms(text, max_chars=900) - assert len(parts) == 3 - assert all(len(p) <= 900 for p in parts) - assert "".join(parts) == text - - def test_exactly_at_limit_single_part(self): - text = "a" * 900 - assert _split_for_sms(text, max_chars=900) == [text] - - -class TestSendSmsChunking: - def test_long_body_sent_as_multiple_messages(self, monkeypatch): - monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") - long_body = ("Weather update here.\n\n" - "Traffic is normal today.\n\n" - "News: " + "x" * 900) - with patch("palmer.sms_util._twilio") as mock_twilio: - mock_twilio.messages.create.return_value = MagicMock() - result = send_sms("+15551234567", long_body) - assert result is True - assert mock_twilio.messages.create.call_count == 3 - bodies = [c.kwargs["body"] for c in mock_twilio.messages.create.call_args_list] - assert bodies[0] == "Weather update here." - assert bodies[1] == "Traffic is normal today." - assert "News:" in bodies[2] - - def test_short_body_sent_as_one_message(self, monkeypatch): - monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") - with patch("palmer.sms_util._twilio") as mock_twilio: - mock_twilio.messages.create.return_value = MagicMock() - result = send_sms("+15551234567", "quick note") - assert result is True - assert mock_twilio.messages.create.call_count == 1 - assert mock_twilio.messages.create.call_args.kwargs["body"] == "quick note" - - def test_no_content_lost_across_chunks(self, monkeypatch): - monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") - long_body = "\n\n".join(f"Topic {i}: some detail about it." for i in range(20)) - with patch("palmer.sms_util._twilio") as mock_twilio: - mock_twilio.messages.create.return_value = MagicMock() - send_sms("+15551234567", long_body) - bodies = [c.kwargs["body"] for c in mock_twilio.messages.create.call_args_list] - assert "\n\n".join(bodies) == long_body diff --git a/tests/test_sources.py b/tests/test_sources.py deleted file mode 100644 index 47193f2..0000000 --- a/tests/test_sources.py +++ /dev/null @@ -1,335 +0,0 @@ -"""Tests for sources.py — the one place that decides which news Palmer repeats. - -Every news surface (watch alerts, the morning briefing, Palmer Home, and the -conversation search) reaches this module through datafeeds._search_raw, so a -regression here is a regression everywhere at once. The integration class at -the bottom pins that wiring; the rest pin the gate itself. - -trusted_sources.json is meant to be hand-edited without touching code, which -makes TestSourceListIntegrity load-bearing rather than pedantic — a stray -"https://" or a domain listed in both the allowlist and the blocklist would -otherwise fail silently at runtime. -""" -import json -from datetime import datetime, timezone, timedelta -from pathlib import Path -from unittest.mock import patch, MagicMock - -from palmer import sources -from palmer import datafeeds - - -def _fresh(hours_ago: float = 1) -> str: - return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat() - - -def _r(url: str, score: float = 0.7, hours_ago: float = 1) -> dict: - return {"url": url, "title": "t", "content": "c", "score": score, - "published_date": _fresh(hours_ago)} - - -class TestIsBlocked: - def test_press_release_wire_blocked(self): - assert sources.is_blocked("https://www.prnewswire.com/news-releases/thing-123.html") - assert sources.is_blocked("https://globenewswire.com/x") - - def test_republishing_aggregator_blocked(self): - assert sources.is_blocked("https://www.msn.com/en-us/news/other/story") - assert sources.is_blocked("https://biztoc.com/x/abc") - assert sources.is_blocked("https://news.google.com/articles/xyz") - - def test_subdomain_of_blocked_host_blocked(self): - assert sources.is_blocked("https://ir.globenewswire.com/x") - - def test_real_newsroom_not_blocked(self): - assert not sources.is_blocked("https://www.reuters.com/world/x") - - def test_lookalike_suffix_not_blocked(self): - """notmsn.com must not match msn.com — suffix matching is on a dot boundary.""" - assert not sources.is_blocked("https://notmsn.com/x") - - def test_malformed_and_empty_are_not_blocked(self): - assert not sources.is_blocked("") - assert not sources.is_blocked("not a url") - - -class TestSourceTier: - def test_tier1_newsroom(self): - assert sources.source_tier("https://www.reuters.com/world/x") == 1 - - def test_tier2_outlet(self): - assert sources.source_tier("https://www.theverge.com/x") == 2 - - def test_unknown_is_tier3(self): - assert sources.source_tier("https://contentfarm.example/x") == 3 - - def test_gov_and_edu_are_tier1(self): - assert sources.source_tier("https://www.weather.gov/alert/1") == 1 - assert sources.source_tier("https://news.mit.edu/2026/x") == 1 - - def test_subdomain_inherits_tier(self): - assert sources.source_tier("https://feeds.bbc.co.uk/news/x") == 1 - - def test_lookalike_suffix_does_not_inherit_tier(self): - """A domain ending in the trusted string but not on a dot boundary is - tier 3 — otherwise notreuters.com would launder itself into tier 1.""" - assert sources.source_tier("https://notreuters.com/x") == 3 - assert sources.source_tier("https://reuters.com.evil.example/x") == 3 - - def test_blocked_domain_still_reports_tier3(self): - """is_blocked is the gate; tier is only an ordering. They are separate - so a blocked host can never be ranked as merely-untrusted by accident.""" - assert sources.source_tier("https://www.msn.com/x") == 3 - - def test_malformed_url_is_tier3(self): - assert sources.source_tier("") == 3 - assert sources.source_tier("garbage") == 3 - - -class TestCanonicalDomain: - def test_collapses_subdomain_to_known_domain(self): - assert sources.canonical_domain("https://www.reuters.com/x") == "reuters.com" - assert sources.canonical_domain("https://feeds.bbc.co.uk/n") == "bbc.co.uk" - - def test_unknown_domain_falls_back_to_last_two_labels(self): - assert sources.canonical_domain("https://a.b.contentfarm.example/x") == "contentfarm.example" - - def test_blocked_domains_are_canonicalized_too(self): - """Blocked hosts are normally dropped before counting, but corroboration - must not treat two msn.com copies as two independent sources if one ever - reaches it.""" - assert sources.canonical_domain("https://www.msn.com/en-us/x") == "msn.com" - - def test_empty_on_garbage(self): - assert sources.canonical_domain("") == "" - assert sources.canonical_domain("not a url") == "" - - -class TestRank: - def test_drops_blocked_results(self): - out = sources.rank([_r("https://www.msn.com/a"), _r("https://www.reuters.com/b")]) - assert [r["url"] for r in out] == ["https://www.reuters.com/b"] - - def test_tier_beats_score(self): - """A wire report at score 0.5 outranks a content farm at 0.99 — Tavily's - score measures query match, not whether the page is worth believing.""" - out = sources.rank([ - _r("https://contentfarm.example/a", score=0.99), - _r("https://www.reuters.com/b", score=0.50), - ]) - assert [r["url"] for r in out] == ["https://www.reuters.com/b", - "https://contentfarm.example/a"] - - def test_score_breaks_ties_within_a_tier(self): - out = sources.rank([ - _r("https://www.reuters.com/low", score=0.4), - _r("https://apnews.com/high", score=0.9), - ]) - assert [r["url"] for r in out] == ["https://apnews.com/high", - "https://www.reuters.com/low"] - - def test_missing_score_does_not_raise(self): - out = sources.rank([{"url": "https://www.reuters.com/a"}]) - assert len(out) == 1 - - def test_trusted_only_drops_tier3(self): - out = sources.rank([ - _r("https://contentfarm.example/a", score=0.99), - _r("https://www.theverge.com/b", score=0.1), - ], trusted_only=True) - assert [r["url"] for r in out] == ["https://www.theverge.com/b"] - - def test_trusted_only_can_return_nothing(self): - """Palmer Home would rather show no row than an untrusted one.""" - assert sources.rank([_r("https://contentfarm.example/a")], trusted_only=True) == [] - - def test_default_keeps_tier3_as_last_resort(self): - """Conversation and the morning briefing answer a real question, so an - obscure-but-real source beats 'nothing found'.""" - out = sources.rank([_r("https://contentfarm.example/a")]) - assert len(out) == 1 - - def test_empty_input(self): - assert sources.rank([]) == [] - - -class TestMeetsScore: - """The relevance floor runs before the tier sort, so a flat floor lets an - SEO-tuned content farm survive a cut that a real newsroom does not.""" - - def test_tier3_gets_no_slack(self): - assert not sources.meets_score("https://contentfarm.example/a", 0.45, 0.5) - assert sources.meets_score("https://contentfarm.example/a", 0.50, 0.5) - - def test_trusted_source_clears_a_lower_bar(self): - assert sources.meets_score("https://www.reuters.com/a", 0.45, 0.5) - assert sources.meets_score("https://www.theverge.com/a", 0.40, 0.5) - - def test_trusted_slack_is_bounded(self): - """Relaxed, not waived — an off-topic wire story is still off-topic.""" - assert not sources.meets_score("https://www.reuters.com/a", 0.20, 0.5) - - def test_missing_score_never_passes_a_positive_floor(self): - assert not sources.meets_score("https://www.reuters.com/a", None, 0.5) - - def test_floor_never_goes_negative(self): - assert sources.meets_score("https://www.reuters.com/a", 0.0, 0.1) - - -class TestSourceListIntegrity: - """trusted_sources.json is edited by hand and read at import. These pin the - shape so a typo fails the suite instead of silently demoting a newsroom.""" - - @classmethod - def setup_class(cls): - with open(Path(sources.__file__).parent / "trusted_sources.json") as f: - cls.data = json.load(f) - - def test_every_allowlist_entry_has_domain_and_valid_tier(self): - for d in self.data["domains"]: - assert d.get("domain"), f"entry missing domain: {d}" - assert d.get("tier") in (1, 2), f"{d['domain']} has tier {d.get('tier')}" - - def test_every_blocked_entry_explains_why(self): - """The 'why' is the guardrail against the list drifting from structural - junk into editorial opinion about who reports well.""" - for d in self.data["blocked"]: - assert d.get("domain"), f"blocked entry missing domain: {d}" - assert d.get("why"), f"{d['domain']} blocked with no reason given" - - def test_domains_are_bare_hosts(self): - for d in self.data["domains"] + self.data["blocked"]: - host = d["domain"] - assert host == host.lower(), f"{host} is not lowercase" - assert "://" not in host, f"{host} includes a scheme" - assert "/" not in host, f"{host} includes a path" - assert not host.startswith("www."), f"{host} should not carry a www. prefix" - assert "." in host, f"{host} is not a domain" - - def test_no_duplicate_domains(self): - hosts = [d["domain"] for d in self.data["domains"]] - assert len(hosts) == len(set(hosts)), \ - f"duplicates: {sorted({h for h in hosts if hosts.count(h) > 1})}" - - def test_allowlist_and_blocklist_do_not_overlap(self): - allowed = {d["domain"] for d in self.data["domains"]} - blocked = {d["domain"] for d in self.data["blocked"]} - assert not (allowed & blocked), f"listed as both trusted and blocked: {allowed & blocked}" - - def test_loaded_sets_match_the_file(self): - assert len(sources._TIER1_DOMAINS) + len(sources._TIER2_DOMAINS) == len(self.data["domains"]) - assert len(sources._BLOCKED_DOMAINS) == len(self.data["blocked"]) - - -class TestCorroboratedIsShared: - """test_corroboration.py imports corroborated from watches. It moved - here; this pins that watches still exposes the same object so the gate can - never fork into two implementations.""" - - def test_watches_reexports_the_same_function(self): - from palmer import watches - assert watches.corroborated is sources.corroborated - - -class TestSearchRawAppliesTheGate: - """The wiring that matters: every news surface goes through _search_raw, so - the gate has to be applied there rather than by each caller.""" - - def _tavily_returning(self, results): - fake = MagicMock() - fake.search.return_value = {"results": results} - return fake - - def test_blocked_results_never_reach_callers(self): - fake = self._tavily_returning([ - _r("https://www.prnewswire.com/a", score=0.99), - _r("https://www.reuters.com/b", score=0.6), - ]) - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search_raw("some topic") - assert [r["url"] for r in out] == ["https://www.reuters.com/b"] - - def test_results_come_back_source_ranked(self): - fake = self._tavily_returning([ - _r("https://contentfarm.example/a", score=0.99), - _r("https://apnews.com/b", score=0.6), - ]) - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search_raw("some topic") - assert [r["url"] for r in out] == ["https://apnews.com/b", - "https://contentfarm.example/a"] - - def test_trusted_only_is_forwarded(self): - fake = self._tavily_returning([_r("https://contentfarm.example/a")]) - with patch.object(datafeeds, "_tavily", fake): - assert datafeeds._search_raw("t", trusted_only=True) == [] - assert len(datafeeds._search_raw("t")) == 1 - - def test_recency_and_score_filters_still_apply_first(self): - fake = self._tavily_returning([ - _r("https://apnews.com/stale", hours_ago=48), - _r("https://apnews.com/weak", score=0.1), # under the floor even with slack - _r("https://apnews.com/good"), - ]) - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search_raw("t", max_age_hours=12, min_score=0.5) - assert [r["url"] for r in out] == ["https://apnews.com/good"] - - def test_trusted_source_survives_a_cut_that_drops_a_content_farm(self): - """The end-to-end shape of the bug: the farm scores higher because it is - built to, and a flat floor would have left it as the only survivor.""" - fake = self._tavily_returning([ - _r("https://contentfarm.example/a", score=0.55), - _r("https://www.reuters.com/b", score=0.42), - ]) - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search_raw("t", min_score=0.5) - assert [r["url"] for r in out] == ["https://www.reuters.com/b", - "https://contentfarm.example/a"] - - def test_pulls_ten_candidates(self): - """The recency window throws most of a page away. Five candidates left - the tier sort nothing to choose between, which is how a lone content - farm became the best available source. Tavily bills per search, not per - result, so the wider pull is free.""" - fake = self._tavily_returning([]) - with patch.object(datafeeds, "_tavily", fake): - datafeeds._search_raw("t") - assert fake.search.call_args.kwargs["max_results"] == 10 - - def test_search_failure_returns_empty(self): - fake = MagicMock() - fake.search.side_effect = RuntimeError("tavily down") - with patch.object(datafeeds, "_tavily", fake): - assert datafeeds._search_raw("t") == [] - - -class TestConversationSearchLabelsSources: - """_search feeds the drafting model directly. It had no provenance at all, - so Palmer could not tell a wire report from a content farm.""" - - def test_domain_is_labelled_and_junk_is_dropped(self): - fake = MagicMock() - fake.search.return_value = {"results": [ - _r("https://www.msn.com/junk"), - _r("https://www.reuters.com/real"), - ]} - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search("what happened") - assert "[reuters.com]" in out - assert "msn.com" not in out - - def test_no_results_message_when_everything_is_filtered(self): - """An empty search says so — and says it is empty, not broken. - - This used to assert the literal "No results found.", which is the shape - of failure string that gets paraphrased into "I can't find news on that" - and then into a competitor. The gates firing is the common case, not a - rare one, so what the model is told here matters.""" - fake = MagicMock() - fake.search.return_value = {"results": [_r("https://biztoc.com/a")]} - with patch.object(datafeeds, "_tavily", fake): - out = datafeeds._search("q") - assert "'q'" in out # names what was searched for - assert "DO have news search" in out # never implies the capability is gone - assert "another site" in out # and never hands them off diff --git a/tests/test_sports.py b/tests/test_sports.py index 52719c7..c6ee823 100644 --- a/tests/test_sports.py +++ b/tests/test_sports.py @@ -503,20 +503,14 @@ def test_the_job_never_raises(self): class TestStoredState: - def _fresh(self, tmp_path, monkeypatch): - monkeypatch.setattr(db, "_DB_PATH", tmp_path / "g.db") - db.init_db() - - def test_a_sent_alert_counts_and_a_silent_update_does_not(self, tmp_path, monkeypatch): - self._fresh(tmp_path, monkeypatch) + def test_a_sent_alert_counts_and_a_silent_update_does_not(self, fresh_db): db.record_game_alert("+1", "9", 7, 0, "home", "in", sent=True) db.record_game_alert("+1", "9", 14, 0, "home", "in", sent=False) row = db.get_game_alert("+1", "9") assert row["alert_count"] == 1 assert row["home_score"] == 14 - def test_state_is_per_user(self, tmp_path, monkeypatch): - self._fresh(tmp_path, monkeypatch) + def test_state_is_per_user(self, fresh_db): db.record_game_alert("+1", "9", 7, 0, "home", "in", sent=True) assert db.get_game_alert("+2", "9") is None diff --git a/tests/test_subject_dedup.py b/tests/test_subject_dedup.py deleted file mode 100644 index 2f31ce2..0000000 --- a/tests/test_subject_dedup.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for cross-job subject-dedup: userprofile._is_duplicate_subject and its wiring -into watches.py and followup.py. Pure logic + mocked LLM/DB — no real network -or LLM calls. Run: pytest test_subject_dedup.py""" -from dotenv import load_dotenv -load_dotenv() - -from unittest.mock import patch, MagicMock - -from palmer import userprofile -from palmer import watches -from palmer import followup - - -def _haiku_response(text: str) -> MagicMock: - resp = MagicMock() - resp.content = [MagicMock(text=text)] - return resp - - -class TestIsDuplicateSubject: - def test_no_recent_messages_returns_false_without_llm_call(self): - with patch("palmer.db.get_recent_assistant_messages", return_value=[]), \ - patch("palmer.userprofile.client") as mock_client: - assert userprofile._is_duplicate_subject("+15551234567", "new message") is False - mock_client.messages.create.assert_not_called() - - def test_haiku_says_yes_returns_true(self): - with patch("palmer.db.get_recent_assistant_messages", return_value=["Hurts practice update"]), \ - patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_response("YES") - assert userprofile._is_duplicate_subject("+15551234567", "Hurts camp footage") is True - - def test_haiku_says_no_returns_false(self): - with patch("palmer.db.get_recent_assistant_messages", return_value=["weather update"]), \ - patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_response("NO") - assert userprofile._is_duplicate_subject("+15551234567", "bitcoin price") is False - - def test_llm_failure_fails_open(self): - with patch("palmer.db.get_recent_assistant_messages", return_value=["something"]), \ - patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.side_effect = Exception("API down") - assert userprofile._is_duplicate_subject("+15551234567", "new message") is False - - -def _watch(**overrides): - base = { - "id": 1, "phone": "+15551234567", "description": "Eagles Hurts injury", - "queries": ["Jalen Hurts injury"], "cooldown_hours": 4, - "last_alerted": None, "last_alert_summary": None, - "daily_alert_count": 0, "daily_alert_date": None, "recent_summaries": [], - } - base.update(overrides) - return base - - -_RAW_RESULTS = [ - {"title": "Hurts injury update", "url": "https://example1.com/a", - "content": "details", "published_date": "2026-08-13", "score": 1.0}, - {"title": "Hurts injury update 2", "url": "https://example2.com/b", - "content": "more details", "published_date": "2026-08-13", "score": 1.0}, -] - - -class TestWatchesSubjectDedup: - def test_skips_send_when_duplicate_subject(self): - with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ - patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ - patch("palmer.watches._check_watch_hit", return_value=True), \ - patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("palmer.watches._is_duplicate_subject", return_value=True) as dedup, \ - patch("palmer.watches.claim_watch_alert") as mock_claim, \ - patch("palmer.watches.update_watch_alerted") as mock_update, \ - patch("palmer.sms_util.send_sms") as mock_send: - watches.run_watches() - dedup.assert_called_once() - mock_claim.assert_not_called() - mock_send.assert_not_called() - mock_update.assert_not_called() - - def test_sends_normally_when_not_duplicate(self): - with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ - patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ - patch("palmer.watches._check_watch_hit", return_value=True), \ - patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("palmer.watches._is_duplicate_subject", return_value=False), \ - patch("palmer.watches.claim_watch_alert", return_value=True), \ - patch("palmer.watches._draft_alert", return_value="Hurts is out. https://example.com/a"), \ - patch("palmer.watches.update_watch_alerted") as mock_update, \ - patch("palmer.sms_util.send_sms") as mock_send: - watches.run_watches() - mock_send.assert_called_once() - mock_update.assert_called_once() - # What goes out is the drafted line, not the raw headline. - assert mock_send.call_args[0][1] == "Hurts is out. https://example.com/a" - - def test_alert_persists_the_fired_url_and_domain(self): - """The 'Watching' page section links a watch to the article - that fired it — update_watch_alerted must be called with that URL/domain, - not just the dedup title.""" - with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ - patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ - patch("palmer.watches._check_watch_hit", return_value=True), \ - patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("palmer.watches._is_duplicate_subject", return_value=False), \ - patch("palmer.watches.claim_watch_alert", return_value=True), \ - patch("palmer.watches._draft_alert", return_value="drafted line https://example.com/a"), \ - patch("palmer.watches.update_watch_alerted") as mock_update, \ - patch("palmer.sms_util.send_sms"): - watches.run_watches() - _, kwargs = mock_update.call_args - assert kwargs["url"] == "https://example1.com/a" - assert kwargs["domain"] == "example1.com" - - -class TestFollowupSubjectDedup: - def _profile(self): - return {"morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": ["a big life event"]} - - def test_skips_send_and_releases_claim_when_duplicate(self): - with patch("palmer.followup.get_all_profiles", - return_value=[("+15551234567", self._profile())]), \ - patch("palmer.followup._should_send_followup", return_value=True), \ - patch("palmer.followup.claim_daily_guard", return_value=True), \ - patch("palmer.followup.get_history", return_value=[]), \ - patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ - patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ - patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ - patch("palmer.followup._is_duplicate_subject", return_value=True) as dedup, \ - patch("palmer.followup.upsert_profile") as mock_upsert, \ - patch("palmer.followup._local_today") as mock_today, \ - patch("palmer.sms_util.send_sms") as mock_send: - mock_today.return_value.isoformat.return_value = "2026-08-13" - followup.run_followups() - dedup.assert_called_once() - mock_send.assert_not_called() - assert any( - c.args[1] == {"followup_sent_date": None} for c in mock_upsert.call_args_list - ), "expected the daily claim to be released" - - def test_sends_normally_when_not_duplicate(self): - with patch("palmer.followup.get_all_profiles", - return_value=[("+15551234567", self._profile())]), \ - patch("palmer.followup._should_send_followup", return_value=True), \ - patch("palmer.followup.claim_daily_guard", return_value=True), \ - patch("palmer.followup.get_history", return_value=[]), \ - patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ - patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ - patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ - patch("palmer.followup._is_duplicate_subject", return_value=False), \ - patch("palmer.followup.save_message") as mock_save, \ - patch("palmer.followup._local_today") as mock_today, \ - patch("palmer.sms_util.send_sms") as mock_send: - mock_today.return_value.isoformat.return_value = "2026-08-13" - followup.run_followups() - mock_send.assert_called_once() - mock_save.assert_called_once() diff --git a/tests/test_tapback.py b/tests/test_tapback.py deleted file mode 100644 index 05edff5..0000000 --- a/tests/test_tapback.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Tests for inbound reaction handling. - -The core requirement is negative — Palmer must send NOTHING when someone -reacts — so the handler tests assert on calls that must not happen. - -main.py calls _scheduler.start() at import, and send_due_reminders runs every -minute and sends real SMS. Neutralize it before importing main; production code -stays untouched. -""" -from unittest.mock import patch - -import pytest - -from palmer import tapback - -with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - from palmer import main - - -class TestAppleTapbacks: - @pytest.mark.parametrize("body,kind,sentiment", [ - ('Liked "The audacity of it."', "liked", "positive"), - ('Loved "Peno on Clayton."', "loved", "positive"), - ('Laughed at "Airport beer or airport spiral."', "laughed", "positive"), - ('Emphasized "Get the short rib."', "emphasized", "positive"), - ('Disliked "Every six months, same speech."', "disliked", "negative"), - ('Questioned "It\'s Wednesday."', "questioned", "neutral"), - ]) - def test_all_six_parse(self, body, kind, sentiment): - r = tapback.parse_reaction(body) - assert r is not None, f"{body!r} should parse as a reaction" - assert r["kind"] == kind - assert r["sentiment"] == sentiment - - def test_quoted_text_is_captured(self): - r = tapback.parse_reaction('Liked "The audacity of it. Every single week."') - assert r["quoted"] == "The audacity of it. Every single week." - - def test_curly_quotes(self): - r = tapback.parse_reaction('Liked “the audacity of it”') - assert r is not None and r["quoted"] == "the audacity of it" - - def test_truncated_original_keeps_text(self): - r = tapback.parse_reaction('Liked "this is a long message that got cut off…"') - assert r is not None - assert not r["quoted"].endswith("…") - - def test_multiline_quote(self): - r = tapback.parse_reaction('Liked "line one\nline two"') - assert r is not None and "line two" in r["quoted"] - - def test_long_quote_is_truncated(self): - r = tapback.parse_reaction('Liked "' + "x" * 500 + '"') - assert len(r["quoted"]) <= tapback._QUOTE_TRUNCATE - - -class TestEmojiReactions: - def test_ios18_reacted_form(self): - r = tapback.parse_reaction('Reacted \U0001f602 to "that traffic take"') - assert r["kind"] == "emoji" - assert r["sentiment"] == "positive" - assert r["quoted"] == "that traffic take" - - def test_google_messages_form(self): - r = tapback.parse_reaction('\U0001f44d to "see you then"') - assert r["kind"] == "emoji" and r["sentiment"] == "positive" - - def test_bare_emoji(self): - r = tapback.parse_reaction("\U0001f44d") - assert r["kind"] == "emoji" and r["quoted"] == "" - - def test_bare_negative_emoji(self): - assert tapback.parse_reaction("\U0001f44e")["sentiment"] == "negative" - - def test_multiple_emoji_only(self): - assert tapback.parse_reaction("\U0001f525\U0001f4af") is not None - - def test_emoji_with_variation_selector(self): - assert tapback.parse_reaction("❤️") is not None - - -class TestNotReactions: - """False positives are the expensive failure — a real message met with silence.""" - - @pytest.mark.parametrize("body", [ - "Liked it", # no quoted original - "I liked that restaurant you mentioned", # lowercase, mid-sentence - "Loved the game last night", - 'He said "hello" to me', - "Disliked by everyone apparently", - "what's the weather", - "\U0001f44d sounds good to me", # emoji plus real content - "thanks \U0001f602", - "", - " ", - ]) - def test_normal_messages_are_not_reactions(self, body): - assert tapback.parse_reaction(body) is None, f"{body!r} wrongly parsed as a reaction" - - def test_none_body(self): - assert tapback.parse_reaction(None) is None - - def test_is_emoji_only_rejects_empty(self): - assert not tapback.is_emoji_only("") - assert not tapback.is_emoji_only(" ") - - -class TestRecording: - def test_appends_and_caps(self): - stored = {} - - def _upsert(phone, updates): - stored.update(updates) - - with patch.object(tapback, "get_profile", side_effect=lambda p: dict(stored)), \ - patch.object(tapback, "upsert_profile", side_effect=_upsert): - for i in range(tapback.MAX_STORED_REACTIONS + 5): - tapback.record_reaction("+15550001111", { - "kind": "liked", "sentiment": "positive", - "quoted": f"msg {i}", "emoji": "", - }) - - log = stored["reactions"] - assert len(log) == tapback.MAX_STORED_REACTIONS, "rolling log must be capped" - assert log[-1]["quoted"] == f"msg {tapback.MAX_STORED_REACTIONS + 4}", "keeps newest" - - def test_db_failure_is_swallowed(self): - """A bookkeeping failure must never bubble up and trigger a reply.""" - with patch.object(tapback, "get_profile", side_effect=RuntimeError("db down")): - tapback.record_reaction("+15550001111", { - "kind": "liked", "sentiment": "positive", "quoted": "x", "emoji": "", - }) - - -class TestReactionBlock: - def test_empty_without_reactions(self): - assert tapback.reaction_block({}) == "" - assert tapback.reaction_block({"reactions": []}) == "" - assert tapback.reaction_block(None) == "" - - def test_renders_recent_reactions(self): - block = tapback.reaction_block({"reactions": [ - {"kind": "liked", "sentiment": "positive", "quoted": "the audacity of it", "emoji": ""}, - {"kind": "disliked", "sentiment": "negative", "quoted": "long all-hands take", "emoji": ""}, - ]}) - assert "the audacity of it" in block - assert "long all-hands take" in block - - def test_surfaced_by_build_system(self): - from palmer import agent - profile = {"name": "Mike", "reactions": [ - {"kind": "liked", "sentiment": "positive", "quoted": "the audacity of it", "emoji": ""}, - ]} - with patch.object(agent, "get_profile", return_value=profile), \ - patch.object(agent, "get_user_watches", return_value=[]), \ - patch.object(agent, "get_user_price_watches", return_value=[]): - out = agent._build_system("+15550001111") - assert "HOW THEY'VE REACTED" in out - assert "the audacity of it" in out - - -class TestHandlerSendsNothing: - """The actual requirement: a reaction produces no outbound message at all.""" - - def _run(self, body, media_url=None): - with patch.object(main, "ensure_sms") as ensure, \ - patch.object(main, "send_sms") as send, \ - patch.object(main, "get_reply") as get_reply, \ - patch.object(main, "save_message"), \ - patch.object(main, "get_history", return_value=[]), \ - patch.object(main, "get_profile", return_value={"intro_sent": True}), \ - patch.object(main, "upsert_profile") as upsert, \ - patch.object(main, "record_reaction") as record: - main._handle_sms("+15550001111", body, media_url) - return ensure, send, get_reply, record, upsert - - def test_tapback_sends_nothing(self): - ensure, send, get_reply, record, _ = self._run('Liked "the audacity of it"') - ensure.assert_not_called() - send.assert_not_called() - get_reply.assert_not_called() - record.assert_called_once() - - def test_no_fallback_sms_on_reaction(self): - """_handle_sms fires FALLBACK_SMS when the inner call returns falsy.""" - ensure, _, _, _, _ = self._run("\U0001f44d") - ensure.assert_not_called() - - def test_reaction_does_not_mark_intro_sent(self): - _, _, _, _, upsert = self._run('Liked "hi"') - upsert.assert_not_called() - - def test_normal_message_still_replies(self): - with patch.object(main, "ensure_sms", return_value=True) as ensure, \ - patch.object(main, "get_reply", return_value=("sure thing", None)) as get_reply, \ - patch.object(main, "save_message"), \ - patch.object(main, "save_assistant_turn"), \ - patch.object(main, "get_history", return_value=[]), \ - patch.object(main, "get_profile", return_value={"intro_sent": True}), \ - patch.object(main, "upsert_profile"): - main._handle_sms("+15550001111", "what's the weather", None) - get_reply.assert_called_once() - ensure.assert_called_once() - - def test_photo_is_never_a_reaction(self): - """An emoji caption on an MMS must not swallow the photo.""" - _, _, get_reply, record, _ = self._run("\U0001f44d", media_url="http://x/img.jpg") - record.assert_not_called() - get_reply.assert_called_once() diff --git a/tests/test_tickers.py b/tests/test_tickers.py deleted file mode 100644 index 7958441..0000000 --- a/tests/test_tickers.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Topic -> symbol resolution. - -The Markets section is derived from morning topics, so this resolver decides -whether "add Nvidia to my site" produces a price or silently produces nothing. -It used to be a bare uppercase-word regex, which meant it worked only when the -model happened to write the ticker into the topic itself. - -Two failure directions matter and both are tested: a real company that resolves -to NOTHING (silently empty Markets section) and a non-company that resolves to -SOMETHING (a wrong price on someone's personal page). The second is worse. -""" -from unittest.mock import patch - -import pytest - -from palmer import tickers -from palmer.tickers import resolve_topic_asset as resolve - - -class TestCompanyNames: - """The actual bug: users say "Nvidia", not "NVDA".""" - - @pytest.mark.parametrize("topic,symbol", [ - ("Nvidia stock", "NVDA"), - ("Tesla stock", "TSLA"), - ("Apple shares", "AAPL"), - ("coinbase stock", "COIN"), - ("microsoft stock price", "MSFT"), - ("what's amazon stock doing", "AMZN"), - ]) - def test_a_company_name_resolves(self, topic, symbol): - assert resolve(topic)[0] == symbol - - def test_case_does_not_matter(self): - assert resolve("NVIDIA STOCK")[0] == "NVDA" - assert resolve("nvidia stock")[0] == "NVDA" - - def test_the_longest_name_wins(self): - """"dow jones" must not be shadowed by a shorter key.""" - assert resolve("dow jones")[0] == "^DJI" - assert resolve("s&p 500")[0] == "^GSPC" - - -class TestExplicitSymbols: - def test_a_parenthesised_ticker_is_used(self): - """This is the shape the drafting model writes on its own.""" - assert resolve("Nvidia stock price (NVDA)")[0] == "NVDA" - - def test_a_dollar_prefixed_ticker_is_used(self): - assert resolve("$TSLA")[0] == "TSLA" - - def test_a_bare_ticker_with_a_price_word_is_used(self): - assert resolve("TSLA stock")[0] == "TSLA" - - def test_an_explicit_symbol_beats_the_name_map(self): - """If the user spelled out a symbol, trust it over a name match.""" - assert resolve("Alphabet stock (GOOG)")[0] == "GOOG" - - -class TestFalsePositives: - """A wrong ticker is worse than no ticker — it puts a real price for the - wrong thing on someone's page, and nothing downstream can catch it.""" - - def test_us_stock_market_is_not_the_ticker_US(self): - """The live bug: "US stock market" resolved to "US", which yfinance - rejects as delisted on every single page refresh.""" - got = resolve("US stock market") - assert got is not None and got[0] != "US" - - def test_the_generic_market_ask_resolves_to_the_sp500(self): - assert resolve("US stock market")[0] == "^GSPC" - assert resolve("stock market updates")[0] == "^GSPC" - - @pytest.mark.parametrize("topic", [ - "AI news", "US politics", "Fintech news", "movie news", - "St. Louis Cardinals", "Philadelphia Eagles news", - "National and international news", "Kirkwood, MO weather", - "Trump social media posts (overnight)", "Daily fun fact from history", - "SpaceX news", - ]) - def test_a_news_topic_gets_no_ticker(self, topic): - assert resolve(topic) is None - - def test_a_price_word_alone_is_not_enough(self): - """"stock" in the sentence must not make any capitalised word a ticker.""" - assert resolve("stock up on groceries") is None - - @pytest.mark.parametrize("word", ["US", "AI", "ETF", "IPO", "CEO", "NFL", "THE"]) - def test_known_non_tickers_are_rejected(self, word): - assert word in tickers.NOT_TICKERS - - -class TestNewsTopicsAreNotPriceTopics: - """A company name is an ordinary word in a news topic. Without a price-word - gate, "SpaceX news" resolves to SPCX and a subject someone follows silently - grows a stock ticker in their Markets section.""" - - @pytest.mark.parametrize("topic", [ - "SpaceX news", "Disney movie news", "Nike news", - "Apple event coverage", "Tesla recall coverage", "Amazon layoffs", - ]) - def test_a_company_in_a_news_topic_gets_no_ticker(self, topic): - assert resolve(topic) is None - - @pytest.mark.parametrize("topic,symbol", [ - ("SpaceX stock", "SPCX"), ("Disney stock", "DIS"), ("Nike stock", "NKE"), - ]) - def test_the_same_company_with_a_price_word_does_resolve(self, topic, symbol): - assert resolve(topic)[0] == symbol - - def test_indices_need_no_price_word(self): - """"nasdaq" is unambiguously a market reference on its own.""" - assert resolve("nasdaq")[0] == "^IXIC" - assert resolve("the dow")[0] == "^DJI" - - def test_crypto_needs_no_price_word(self): - """Pre-existing behaviour: a live user tracks "Bitcoin and major stock - news" and has always gotten a price for it.""" - assert resolve("Bitcoin and major stock news")[0] == "bitcoin" - - -class TestSearchResolution: - """The save-path resolver. No model is asked for a symbol — two earlier - versions did, and both encoded a stale snapshot of who was public.""" - - def _search(self, quotes, topic="Lululemon shares"): - with patch("palmer.netutil._http_get_json", return_value={"quotes": quotes}) as get: - return tickers.search_symbol(topic), get - - def test_a_us_equity_is_returned(self): - assert self._search([{"symbol": "LULU", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "LULU" - - def test_a_tokenized_crypto_is_rejected(self): - """Unfiltered, "openai" comes back as a crypto token that merely shares - the name — a real price for something that is not the company.""" - assert self._search([{"symbol": "OPENAI-USD", "quoteType": "CRYPTOCURRENCY", - "exchange": "CCC"}])[0] is None - - def test_a_thematic_etf_is_rejected(self): - assert self._search([{"symbol": "OAIW", "quoteType": "ETF", "exchange": "PCX"}])[0] is None - - def test_a_mutual_fund_is_rejected(self): - assert self._search([{"symbol": "STRIZZX", "quoteType": "MUTUALFUND", - "exchange": "NAS"}])[0] is None - - def test_a_foreign_listing_is_skipped_for_the_us_one(self): - """"lululemon" also matches Milan and Sao Paulo lines of the same - company, which price in the wrong currency.""" - assert self._search([{"symbol": "1LUL.MI", "quoteType": "EQUITY", "exchange": "MIL"}, - {"symbol": "LULU", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "LULU" - - def test_the_first_qualifying_result_wins(self): - assert self._search([{"symbol": "SPACEX-USD", "quoteType": "CRYPTOCURRENCY", "exchange": "CCC"}, - {"symbol": "SPCX", "quoteType": "EQUITY", "exchange": "NMS"}, - {"symbol": "SPCF", "quoteType": "EQUITY", "exchange": "NMS"}])[0] == "SPCX" - - def test_no_qualifying_result_yields_nothing(self): - assert self._search([])[0] is None - - def test_a_network_failure_yields_nothing(self): - with patch("palmer.netutil._http_get_json", return_value=None): - assert tickers.search_symbol("Lululemon shares") is None - - def test_a_stopword_symbol_is_still_rejected(self): - assert self._search([{"symbol": "AI", "quoteType": "EQUITY", "exchange": "NYQ"}])[0] is None - - def test_no_model_is_consulted(self): - """The whole point of the rewrite.""" - with patch("palmer.llm.client") as client, \ - patch("palmer.netutil._http_get_json", return_value={"quotes": []}): - tickers.resolve_company_ticker("Lululemon shares") - client.messages.create.assert_not_called() - - -class TestSearchQuery: - """Search matches names, not sentences.""" - - def test_price_words_are_stripped(self): - """"spacex stock" returns nothing from search; "spacex" returns SPCX.""" - assert tickers._search_query("spacex stock") == "spacex" - assert tickers._search_query("Lululemon shares") == "Lululemon" - assert tickers._search_query("Duolingo stock price") == "Duolingo" - - def test_topic_filler_is_stripped(self): - assert tickers._search_query("the latest Nvidia news updates") == "Nvidia" - - def test_a_multiword_company_survives(self): - assert tickers._search_query("Rocket Lab stock") == "Rocket Lab" - - def test_an_empty_query_short_circuits(self): - with patch("palmer.netutil._http_get_json") as get: - assert tickers.search_symbol("stock price news") is None - get.assert_not_called() - - def test_indices_never_reach_search(self): - """Search returns futures for them - "s&p 500" is ES=F, not ^GSPC - so - they must resolve from INDEX_TICKERS before search is consulted.""" - for name in ("s&p 500", "nasdaq", "the dow", "US stock market"): - got = resolve(name) - assert got and got[0].startswith("^"), f"{name} must map to an index" - - -class TestCrypto: - def test_crypto_still_resolves(self): - assert resolve("Bitcoin price")[0] == "bitcoin" - assert resolve("bitcoin")[0] == "bitcoin" - - def test_crypto_wins_over_a_stray_ticker_match(self): - assert resolve("Bitcoin and major stock news")[0] == "bitcoin" - - -class TestLabels: - """Yahoo's index symbols are correct and unreadable.""" - - def test_an_index_gets_a_human_label(self): - assert resolve("s&p 500") == ("^GSPC", "S&P 500") - assert resolve("nasdaq") == ("^IXIC", "Nasdaq") - - def test_a_plain_ticker_labels_as_itself(self): - assert resolve("Nvidia stock") == ("NVDA", "NVDA") - - def test_crypto_labels_readably(self): - assert resolve("bitcoin")[1] == "Bitcoin" - - def test_crypto_alias_labels_readably_too(self): - """"Btc"/"Avax" (a naive title-case of the matched alias) used to reach - the page. The coingecko id behind the alias decides the real name.""" - assert resolve("add BTC to my markets")[1] == "Bitcoin" - assert resolve("avax price")[1] == "Avalanche" - assert resolve("what's XRP at")[1] == "XRP" - - -class TestPriceTopicGate: - """Gates the paid fallback so ordinary news topics never trigger one.""" - - @pytest.mark.parametrize("topic", ["Nvidia stock", "bitcoin price", - "AAPL shares", "the market"]) - def test_price_topics_pass(self, topic): - assert tickers.looks_like_price_topic(topic) - - @pytest.mark.parametrize("topic", ["AI news", "St. Louis Cardinals", - "Kirkwood weather", "movie news"]) - def test_news_topics_do_not(self, topic): - assert not tickers.looks_like_price_topic(topic) - - -class TestReadPathIsFree: - def test_resolution_never_calls_a_model(self): - """This runs on every page view. A model call here would be a bill.""" - with patch("palmer.llm.client") as client: - for t in ["Nvidia stock", "AI news", "US stock market", "$TSLA", - "SpaceX stock", "bitcoin", "Lululemon shares"]: - resolve(t) - client.messages.create.assert_not_called() diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..261c525 --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,852 @@ +"""Clocks, dates, reminders and recurrence. + +Merged from test_timeutil.py, test_reminder_time.py, test_reminder_recurrence.py, test_clock_correctness.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import importlib +from datetime import date, datetime, timezone, timedelta +from unittest.mock import patch +from palmer import timeutil, agent, db, datafeeds, userprofile, weather +from zoneinfo import ZoneInfo +from palmer.timeutil import next_occurrence + + +# ============================================================================ +# from test_timeutil.py +# ============================================================================ +# +# Tests for user-local date/time helpers. +# +# Palmer's dyno runs UTC but almost everything user-facing needs the user's +# local calendar day. These tests lock in the semantics (real tz honored, no-tz +# falls back to UTC, unknown tz doesn't raise) and prove the late-night west- +# coast off-by-one is actually fixed. + +class TestLocalNow: + def test_returns_tz_aware_datetime_for_valid_tz(self): + out = timeutil.local_now("America/Los_Angeles") + assert out.tzinfo is not None + # tz name should be present on the returned aware dt + assert "Los_Angeles" in str(out.tzinfo) + + def test_none_tz_falls_back_to_utc(self): + out = timeutil.local_now(None) + assert out.tzinfo == timezone.utc + + def test_empty_tz_falls_back_to_utc(self): + assert timeutil.local_now("").tzinfo == timezone.utc + + def test_bogus_tz_falls_back_to_utc_without_raising(self): + assert timeutil.local_now("Not/A/Real_Zone").tzinfo == timezone.utc + + +class TestLocalToday: + def test_returns_date_type(self): + assert isinstance(timeutil.local_today("America/Chicago"), date) + + def test_none_tz_uses_utc_date(self): + # Freeze datetime.now so we can compare exactly + with patch("palmer.timeutil.datetime") as mock_dt: + mock_dt.now.side_effect = lambda tz=None: datetime(2026, 8, 18, 20, 0, tzinfo=tz or timezone.utc) + assert timeutil.local_today(None) == date(2026, 8, 18) + + def test_west_coast_late_evening_stays_on_yesterday_local(self): + """The bug this whole exercise was for: at 22:00 America/Los_Angeles + on Aug 17 the server is already Aug 18 UTC, so anything using + date.today() sees Aug 18. local_today('America/Los_Angeles') must + still see Aug 17.""" + # Fake datetime.now so it returns Aug 18 05:00 UTC (=Aug 17 22:00 PDT) + with patch("palmer.timeutil.datetime") as mock_dt: + def _now(tz=None): + base = datetime(2026, 8, 18, 5, 0, tzinfo=timezone.utc) + return base.astimezone(tz) if tz else base + mock_dt.now.side_effect = _now + assert timeutil.local_today("America/Los_Angeles") == date(2026, 8, 17) + assert timeutil.local_today(None) == date(2026, 8, 18) # UTC still Aug 18 + + +class TestResolveDayDeltaHonorsTz: + """The end-to-end bug: at 22:00 PDT Thursday, someone asking 'weather + Friday' should get delta=1 (tomorrow), not delta=8 (next Friday).""" + + def test_friday_at_thursday_night_local_is_tomorrow(self): + from palmer.timeutil import resolve_day_delta + # Freeze both timeutil.local_today and weather._date.today so the module + # under test sees a consistent 'now'. + with patch("palmer.timeutil.datetime") as mock_dt: + def _now(tz=None): + # Aug 21 2026 is a Friday. So 05:00 UTC on Aug 21 = 22:00 PDT on Aug 20 (Thursday). + base = datetime(2026, 8, 21, 5, 0, tzinfo=timezone.utc) + return base.astimezone(tz) if tz else base + mock_dt.now.side_effect = _now + + # UTC-only path (no tz): 'friday' seen from Friday should give 7 (next friday) + assert resolve_day_delta("friday", "friday", tz=None) == 7 + # With user tz PDT (still Thursday locally): 'friday' should give 1 (tomorrow) + assert resolve_day_delta("friday", "friday", tz="America/Los_Angeles") == 1 + + def test_tomorrow_always_one_day_out(self): + from palmer.timeutil import resolve_day_delta + assert resolve_day_delta("tomorrow", "tomorrow", tz="America/Chicago") == 1 + assert resolve_day_delta("tomorrow", "tomorrow", tz=None) == 1 + + +# ============================================================================ +# from test_reminder_time.py +# ============================================================================ +# +# The clock the model is given, and what happens to the time it hands back. +# +# Palmer used to be told the dyno's clock and the dyno's date, then asked to work +# the user's timezone out from the city string in their profile. Two conversions +# per reminder, in the model's head, off an anchor that is simply the wrong day +# for every user west of UTC from 5pm local onward. At 17:42 on Sunday in Los +# Angeles the server already reads Monday, so "remind me tomorrow at 9" files for +# Tuesday. The model was not making a mistake; it was told the wrong day. +# +# These tests hold both halves of the fix: the prompt states the user's own day, +# and the server vets what comes back rather than trusting the string. + +PHONE = "+15550001111" + +# 2026-08-31T00:42Z is 2026-08-30 17:42 in Los Angeles — the exact window where +# the server's date and the user's date disagree. +ROLLOVER = datetime(2026, 8, 31, 0, 42, tzinfo=timezone.utc) + + +class TestValidZone: + def test_a_real_zone_resolves(self): + assert timeutil.valid_zone("America/Los_Angeles") == "America/Los_Angeles" + + def test_junk_is_rejected_rather_than_passed_through(self): + assert timeutil.valid_zone("Pacific Time") is None + assert timeutil.valid_zone("US/Notreal") is None + + def test_empty_is_none(self): + assert timeutil.valid_zone(None) is None + assert timeutil.valid_zone("") is None + + +class TestClockBlock: + def test_it_states_the_users_day_not_the_servers(self): + out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) + # The whole point: the server has already rolled over to Monday the + # 31st while the reader is still on Sunday the 30th. + assert "Sunday, August 30, 2026" in out # their today + assert "Monday August 31" in out # their tomorrow + assert "Monday, August 31" in out # the server's day, labelled + assert "America/Los_Angeles" in out + + def test_the_users_today_leads_and_the_server_is_an_aside(self): + out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) + assert out.index("Their local time") < out.index("Server clock") + assert "for your reference only" in out + + def test_the_local_hour_is_local(self): + out = timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) + assert "17:42" in out + + def test_no_zone_asserts_no_local_date(self): + out = timeutil.clock_block(None, now=ROLLOVER) + assert "don't know this person's timezone" in out + assert "Do not state or assume a local date" in out + # The failure being prevented: presenting the server's day as theirs. + assert "Their local time" not in out + + def test_an_unresolvable_zone_degrades_to_the_no_zone_form(self): + out = timeutil.clock_block("Pacific Time", now=ROLLOVER) + assert "don't know this person's timezone" in out + + def test_it_never_raises(self): + for bad in (None, "", "junk", 17, object()): + timeutil.clock_block(bad, now=ROLLOVER) + + +class TestTheClockReachesTheSystemPrompt: + def _build(self, profile: dict) -> str: + with patch.object(agent, "get_profile", return_value=profile), \ + patch.object(agent, "get_user_watches", return_value=[]), \ + patch.object(agent, "get_user_price_watches", return_value=[]): + return agent._build_system(PHONE) + + def test_the_users_zone_is_named(self): + out = self._build({"name": "Ada", "timezone": "America/Chicago"}) + assert "America/Chicago" in out + assert "RIGHT NOW, WHERE THEY ARE" in out + + def test_a_profile_without_a_zone_gets_the_honest_form(self): + out = self._build({"name": "Ada"}) + assert "don't know this person's timezone" in out + + def test_base_system_renders_without_a_profile(self): + out = agent.base_system() + assert "RIGHT NOW" in out + assert "{" not in out.split("RIGHT NOW")[1][:200] + + +class TestNormalizeDueAt: + """The write-path vetting. `due_at` is TEXT and claim_due_reminders orders it + lexicographically, so a wrong shape is not a cosmetic problem — it fires the + reminder at the wrong hour.""" + + def _norm(self, raw, tz="America/Chicago", now=None): + with patch.object(agent, "get_profile", return_value={"timezone": tz}), \ + patch("palmer.agent.datetime") as dt: + dt.now.return_value = now or datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc) + dt.side_effect = datetime + return agent._normalize_due_at(PHONE, raw) + + def test_a_z_suffix_becomes_the_canonical_offset_form(self): + due, label, err = self._norm("2026-08-31T20:00:00Z") + assert err is None + assert due == "2026-08-31T20:00:00+00:00" + + def test_a_non_utc_offset_is_corrected_rather_than_read_as_utc(self): + # This is the five-hours-early bug. 09:00-05:00 is 14:00Z, and the old + # string compare read the literal "09:00" as though it were UTC. + due, label, err = self._norm("2026-08-31T09:00:00-05:00") + assert err is None + assert due == "2026-08-31T14:00:00+00:00" + + def test_a_naive_string_is_read_as_the_users_local_clock(self): + # A model that drops the offset was thinking in the user's day. Reading + # it as UTC would move the reminder by the whole offset. + due, label, err = self._norm("2026-08-31T09:00") + assert err is None + assert due == "2026-08-31T14:00:00+00:00" # 9am Chicago in August = CDT + + def test_a_naive_string_falls_back_to_utc_with_no_zone_on_file(self): + due, label, err = self._norm("2026-08-31T09:00", tz=None) + assert err is None + assert due == "2026-08-31T09:00:00+00:00" + assert "UTC" in label + + def test_a_past_time_is_refused_with_something_actionable(self): + due, label, err = self._norm("2026-08-29T09:00:00Z") + assert due is None + assert "already past" in err + assert "RIGHT NOW" in err # tells the model where to look + + def test_an_unreadable_time_is_refused(self): + due, label, err = self._norm("next thursday-ish") + assert due is None + assert err + + def test_absurdly_far_out_is_refused(self): + due, label, err = self._norm("2099-01-01T09:00:00Z") + assert due is None + assert err + + def test_the_label_is_local_and_carries_no_utc(self): + due, label, err = self._norm("2026-08-31T20:00:00Z") + assert "3:00 PM" in label # 20:00Z is 3pm CDT + assert "UTC" not in label + + +class TestTheDispatchEchoesLocalTime: + def test_a_saved_reminder_reports_the_local_time(self, fresh_db): + with patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}): + due, label, err = agent._normalize_due_at( + PHONE, (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()) + assert err is None + # The old result echoed the raw UTC string, which made the model convert + # a second time for the half the user actually reads. + assert "+00:00" not in label + + +class TestStoredRowsAreCanonical: + def test_save_reminder_rewrites_the_offset(self, fresh_db): + db.save_reminder(PHONE, "call mom", "2026-08-31T09:00:00-05:00") + conn = db._conn() + cur = conn.cursor() + cur.execute(f"SELECT due_at FROM reminders WHERE phone = {db.PH}", (PHONE,)) + stored = cur.fetchone()["due_at"] + conn.close() + assert stored == "2026-08-31T14:00:00+00:00" + + def test_an_unparseable_due_at_is_not_stored(self, fresh_db): + db.save_reminder(PHONE, "call mom", "whenever") + conn = db._conn() + cur = conn.cursor() + cur.execute(f"SELECT COUNT(*) AS c FROM reminders WHERE phone = {db.PH}", (PHONE,)) + assert cur.fetchone()["c"] == 0 + conn.close() + + def test_normalize_repairs_a_legacy_row_and_is_idempotent(self, fresh_db): + conn = db._conn() + cur = conn.cursor() + # Write past save_reminder, the way rows were written before it normalized. + cur.execute(f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", + (PHONE, "old row", "2026-08-31T09:00:00-05:00")) + conn.commit() + conn.close() + assert db.normalize_due_at_rows() == 1 + assert db.normalize_due_at_rows() == 0 + + def test_a_corrected_row_no_longer_fires_early(self, fresh_db): + # 09:00-05:00 is 14:00Z. Lexicographically "09:00" sorts below a 12:00Z + # "now", so before normalization this row was claimed five hours early. + conn = db._conn() + cur = conn.cursor() + cur.execute(f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", + (PHONE, "too early", "2999-01-01T09:00:00-05:00")) + conn.commit() + conn.close() + db.normalize_due_at_rows() + assert db.claim_due_reminders() == [] + + +class TestTheWeekIsATableToReadNotArithmetic: + """clock_block named "today" and "tomorrow" and emitted no ISO date at all, + so anything past tomorrow — "next Friday", "the 15th", "a week Tuesday" — + was the model rebuilding a date from the prose "Friday, September 04, + 2026" and counting forward in its head. That is the one computation on the + reminder path nothing checks: _normalize_due_at catches an unreadable + string, a past time and a date over a year out, but a plausible wrong + Friday sails through and reads correctly in the confirmation. + """ + + def _block(self): + return timeutil.clock_block("America/Los_Angeles", now=ROLLOVER) + + def test_todays_iso_date_is_there_to_be_copied(self): + assert "Sun 2026-08-30 (today)" in self._block() + + def test_the_whole_week_is_dated(self): + out = self._block() + for day in ("Mon 2026-08-31", "Fri 2026-09-04", "Sat 2026-09-05"): + assert day in out + + def test_the_run_is_anchored_on_their_day_not_the_servers(self): + """The server has already rolled to Monday the 31st.""" + out = self._block() + assert out.index("Sun 2026-08-30") < out.index("Mon 2026-08-31") + assert "Mon 2026-08-30" not in out + + def test_todays_weekday_appears_twice(self): + """A bare weekday naming today means the one a week out — that rule + needs a second Sunday in the list to point at.""" + out = self._block() + assert out.count("2026-08-30") == 1 and "Sun 2026-09-06" in out + + def test_the_no_zone_form_labels_them_as_the_servers(self): + out = timeutil.clock_block(None, now=ROLLOVER) + assert "Server dates" in out + assert "Their local time" not in out + # The refusal is still the last word, which is the whole point of the branch. + assert "Do not state or assume a local date" in out + + def test_the_block_stays_small(self): + """It ships on every single turn, so its size is a property worth + holding, not a comment.""" + assert len(self._block()) < 650 + + +class TestOneAnswerForWhatNextFridayMeans: + def test_the_convention_lives_in_timeutil(self): + """It was in weather.py, so the only path where the MODEL computes a + date — reminders — had no answer for "next friday" while the weather + path had a considered one. The same user could get both in one thread.""" + from palmer.timeutil import resolve_day_delta + assert not hasattr(importlib.import_module("palmer.weather"), "_resolve_day_delta") + with patch.object(timeutil, "local_today", return_value=date(2026, 8, 26)): + assert resolve_day_delta("friday", "friday") == 2 # this Friday + assert resolve_day_delta("next friday", "next friday") == 9 + + def test_the_prompt_states_it_where_the_model_computes_the_date(self): + block = agent.SYSTEM_PROMPT.split("REMINDERS")[1].split("MORNING BRIEFING")[0] + assert "AFTER this coming one" in block + assert "read the date off" in block + + def test_the_prompt_says_what_to_do_with_an_unsupported_repeat(self): + """The write path refuses it; the model needs to know what to offer.""" + block = agent.SYSTEM_PROMPT.split("REMINDERS")[1].split("MORNING BRIEFING")[0] + assert "every other Tuesday" in block + assert "won't repeat" in block + + def test_the_due_at_field_points_at_the_block(self): + from palmer.tools_def import TOOLS + schema = next(t for t in TOOLS if t["name"] == "set_reminder") + assert "RIGHT NOW" in schema["input_schema"]["properties"]["due_at"]["description"] + + +# ============================================================================ +# from test_reminder_recurrence.py +# ============================================================================ +# +# Recurring reminders, and the dedup guard that was wrong in both directions. +# +# Production evidence that motivated all of this — one user asked for a *daily* +# update and got four texts in the same minute, then nothing, ever: +# +# 133 | Daily Eagles camp update for the user | 2026-08-11T20:00:00Z +# 134 | Eagles camp update - how the day went | 2026-08-11T20:00:00Z +# 135 | Eagles camp update - how did today's practice go? | 2026-08-11T20:00:00Z +# 136 | Eagles camp update - how the day went | 2026-08-11T20:00:00Z +# +# Two independent defects: reminders had no recurrence, and the duplicate guard +# matched exact text while ignoring due_at entirely. + +CHI = ZoneInfo("America/Chicago") + + +class TestNextOccurrence: + def test_preserves_local_wall_clock_across_dst_end(self): + """The reminder is "3pm", not "20:00Z". Chicago leaves CDT on 2026-11-01, + so holding the UTC instant fixed would walk a 3pm reminder to 2pm and + leave it there. The UTC instant MUST move for the local time to stay put.""" + due = datetime(2026, 10, 31, 15, 0, tzinfo=CHI) + nxt = next_occurrence(due, "daily", "America/Chicago", now=due) + assert nxt.astimezone(CHI).hour == 15 + assert due.astimezone(timezone.utc).hour == 20 # CDT, UTC-5 + assert nxt.astimezone(timezone.utc).hour == 21 # CST, UTC-6 — it moved + + def test_skips_missed_periods_instead_of_bursting(self): + """due_at <= now has catch-up semantics, so advancing by exactly one + period after an outage would fire once per missed day on recovery.""" + due = datetime(2026, 8, 10, 15, 0, tzinfo=CHI) + now = datetime(2026, 8, 13, 18, 0, tzinfo=CHI) # three days stale + nxt = next_occurrence(due, "daily", "America/Chicago", now=now) + assert nxt.astimezone(CHI).date() == datetime(2026, 8, 14).date() + assert nxt > now + + def test_weekdays_rolls_friday_to_monday(self): + friday = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) + assert friday.weekday() == 4 + nxt = next_occurrence(friday, "weekdays", "America/Chicago", now=friday) + assert nxt.astimezone(CHI).weekday() == 0 + assert nxt.astimezone(CHI).hour == 9 + + def test_weekly_keeps_the_weekday(self): + friday = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) + nxt = next_occurrence(friday, "weekly", "America/Chicago", now=friday) + assert nxt.astimezone(CHI).weekday() == 4 + assert (nxt.astimezone(CHI).date() - friday.date()).days == 7 + + def test_unknown_recurrence_is_none(self): + # Guards the send path: an unrecognised value must not re-arm anything. + due = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) + for bad in ("hourly", "monthly", "", None, "DAILY-ISH"): + assert next_occurrence(due, bad, "America/Chicago", now=due) is None + + def test_recurrence_is_case_and_space_tolerant(self): + due = datetime(2026, 8, 14, 9, 0, tzinfo=CHI) + assert next_occurrence(due, " Daily ", "America/Chicago", now=due) is not None + + def test_missing_timezone_falls_back_to_utc(self): + due = datetime(2026, 8, 14, 9, 0, tzinfo=timezone.utc) + nxt = next_occurrence(due, "daily", None, now=due) + assert nxt.hour == 9 + + +class TestDedupGuard: + """Same time AND similar text. Either condition alone gets it wrong.""" + + EAGLES = [ + "Daily Eagles camp update for the user", + "Eagles camp update — how the day went", + "Eagles camp update - how did today's practice go?", + "Eagles camp update - how the day went", + ] + + def test_the_four_eagles_rows_collapse_to_one(self, fresh_db): + for text in self.EAGLES: + db.save_reminder(PHONE, text, "2026-08-11T20:00:00Z") + rows = _pending(PHONE) + assert len(rows) == 1, [r["text"] for r in rows] + + def test_em_dash_vs_hyphen_is_not_a_new_reminder(self, fresh_db): + # Rows 134 and 136 differed by exactly this and both got stored. + db.save_reminder(PHONE, "Eagles camp update — how the day went", "2026-08-11T20:00:00Z") + db.save_reminder(PHONE, "Eagles camp update - how the day went", "2026-08-11T20:00:00Z") + assert len(_pending(PHONE)) == 1 + + def test_same_text_at_a_different_time_is_kept(self, fresh_db): + """The old guard ignored due_at, so this silently dropped the second one.""" + db.save_reminder(PHONE, "Call mom", "2026-08-11T20:00:00Z") + db.save_reminder(PHONE, "Call mom", "2026-08-18T20:00:00Z") + assert len(_pending(PHONE)) == 2 + + def test_different_errands_at_the_same_time_both_survive(self, fresh_db): + # Time alone would have merged these. + db.save_reminder(PHONE, "Call your mom", "2026-08-11T20:00:00Z") + db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z") + assert len(_pending(PHONE)) == 2 + + def test_offset_and_z_forms_compare_equal(self, fresh_db): + db.save_reminder(PHONE, "Move the car", "2026-08-11T20:00:00Z") + db.save_reminder(PHONE, "Move the car", "2026-08-11T20:00:00+00:00") + assert len(_pending(PHONE)) == 1 + + +class TestCancelAndRearm: + def test_cancel_clears_recurrence_so_rearm_cannot_resurrect(self, fresh_db): + """Claiming and cancelling both set sent = 1, so they're indistinguishable + by `sent` alone. cancel_reminders also nulls recurrence, which is what + makes a cancel landing between claim and re-arm stick.""" + db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") + rid = _pending(PHONE)[0]["id"] + + db.cancel_reminders(PHONE) + assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is False + assert _pending(PHONE) == [] + + def test_rearm_makes_a_recurring_reminder_pending_again(self, fresh_db): + db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") + rid = _pending(PHONE)[0]["id"] + db.claim_due_reminders() + assert _pending(PHONE) == [] + assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is True + assert len(_pending(PHONE)) == 1 + + def test_one_shot_reminders_are_never_rearmed(self, fresh_db): + db.save_reminder(PHONE, "Confirm lunch", "2026-08-11T20:00:00Z") + rid = _pending(PHONE)[0]["id"] + db.claim_due_reminders() + assert db.rearm_reminder(rid, "2026-08-12T20:00:00Z") is False + + def test_claim_carries_recurrence_and_due_at(self, fresh_db): + db.save_reminder(PHONE, "Take your meds", "2026-08-11T20:00:00Z", "daily") + claimed = db.claim_due_reminders() + assert claimed[0]["recurrence"] == "daily" + # save_reminder canonicalizes on write: one shape in the column, because + # claim_due_reminders orders it lexicographically. + assert claimed[0]["due_at"] == "2026-08-11T20:00:00+00:00" + + +class TestSendLoop: + """End to end through send_due_reminders with Twilio and the drafter stubbed.""" + + def _run(self, profile=None): + from palmer import reminders + sent = [] + with patch("palmer.sms_util.send_sms", side_effect=lambda p, b, **k: sent.append((p, b)) or True), \ + patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ + patch("palmer.reminders.get_profile", return_value=profile or {"timezone": "America/Chicago"}), \ + patch("palmer.reminders.save_message"): + reminders.send_due_reminders() + return sent + + def test_recurring_reminder_sends_then_rearms_into_the_future(self, fresh_db): + past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + db.save_reminder(PHONE, "Take your meds", past, "daily") + + sent = self._run() + assert len(sent) == 1 + + pending = _pending(PHONE) + assert len(pending) == 1, "recurring reminder should be pending again" + assert db._parse_due(pending[0]["due_at"]) > datetime.now(timezone.utc) + + def test_one_shot_reminder_does_not_come_back(self, fresh_db): + past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + db.save_reminder(PHONE, "Confirm lunch with grandpa", past) + assert len(self._run()) == 1 + assert _pending(PHONE) == [] + + def test_near_identical_reminders_due_together_send_once(self, fresh_db): + """The observed failure. These reach the send loop only if they were + written before the dedup guard existed, so the guard is scoped to the + tick rather than to _is_duplicate_subject — a reminder is explicitly + requested, and suppressing one for topical overlap hours later would be + worse than the duplicate it prevents.""" + past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + for text in TestDedupGuard.EAGLES: + _force_insert(PHONE, text, past) + assert len(_pending(PHONE)) == 4 + assert len(self._run()) == 1 + + def test_a_failed_send_still_rearms(self, fresh_db): + """The claim already consumed this occurrence, so bailing on a Twilio + hiccup would silently end a standing reminder.""" + past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + db.save_reminder(PHONE, "Take your meds", past, "daily") + + from palmer import reminders + with patch("palmer.sms_util.send_sms", return_value=False), \ + patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ + patch("palmer.reminders.get_profile", return_value={"timezone": "America/Chicago"}), \ + patch("palmer.reminders.save_message"): + reminders.send_due_reminders() + + assert len(_pending(PHONE)) == 1 + + +# --- helpers ----------------------------------------------------------------- + +def _pending(phone): + conn = db._conn() + cur = conn.cursor() + cur.execute(f"SELECT id, text, due_at, recurrence FROM reminders WHERE phone = {db.PH} AND sent = 0", (phone,)) + rows = [dict(r) for r in cur.fetchall()] + conn.close() + return rows + + +def _force_insert(phone, text, due_at): + """Bypass save_reminder's guard, to recreate rows written before it existed.""" + conn = db._conn() + cur = conn.cursor() + cur.execute( + f"INSERT INTO reminders (phone, text, due_at) VALUES ({db.PH}, {db.PH}, {db.PH})", + (phone, text, due_at), + ) + conn.commit() + conn.close() + + +class TestTheWritePathRefusesARecurrenceItCannotKeep: + """next_occurrence returning None guards the SEND path. Nothing guarded the + write path, and that is where the damage was done. + + A tool-use `enum` is guidance to the model, not a constraint the API + enforces. So "monthly" reached db.save_reminder intact, the dispatch + confirmed "It repeats (monthly) at that same local time until they cancel", + and then the send path quietly declined to re-arm it. The user was told it + repeats, got exactly one text, and had no way to know it had stopped — + which is the outcome SYSTEM_PROMPT and the set_reminder description both + name as the thing never to let happen. + """ + + def _set_reminder(self, recurrence): + """Run one set_reminder tool call and return (result string, saved rows).""" + from unittest.mock import MagicMock + from palmer import agent + + saved = [] + block = MagicMock() + block.type = "tool_use" + block.name = "set_reminder" + block.id = "tu_1" + # Comfortably future but inside _normalize_due_at's 400-day bound, so + # this exercises the recurrence check and not the date check. + due = (datetime.now(timezone.utc) + timedelta(days=30)).replace( + microsecond=0).isoformat() + block.input = {"text": "take the bins out", + "due_at": due, + "recurrence": recurrence} + + calls = [] + + def _create(**kw): + calls.append(kw) + if len(calls) == 1: + return MagicMock(stop_reason="tool_use", content=[block]) + t = MagicMock(type="text", text="done") + return MagicMock(stop_reason="end_turn", content=[t]) + + with patch.object(agent.client.messages, "create", side_effect=_create), \ + patch.object(agent, "_build_system", return_value="sys"), \ + patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ + patch.object(agent, "get_history", return_value=[]), \ + patch.object(agent, "save_reminder", + side_effect=lambda *a, **k: saved.append(a)): + agent.get_reply("+15550001111", "remind me", history=[]) + + results = [c for m in calls[-1]["messages"] for c in (m.get("content") or []) + if isinstance(c, dict) and c.get("type") == "tool_result"] + return results[0]["content"], saved + + def test_an_unsupported_recurrence_saves_nothing(self): + result, saved = self._set_reminder("monthly") + assert saved == [], "a repeat that can never re-arm must not be stored" + assert "Didn't save" in result + + def test_the_model_is_told_what_it_can_use_instead(self): + """A refusal with no alternative is how the model ends up apologising.""" + from palmer.timeutil import RECURRENCES + result, _ = self._set_reminder("every other tuesday") + for supported in RECURRENCES: + assert supported in result + assert "one-time reminder" in result + + def test_a_supported_recurrence_still_saves(self): + _, saved = self._set_reminder("daily") + assert len(saved) == 1 + assert saved[0][3] == "daily" + + def test_it_normalizes_the_way_the_send_path_does(self): + """next_occurrence strips and lowercases; what is stored must match.""" + _, saved = self._set_reminder(" Weekdays ") + assert saved[0][3] == "weekdays" + + def test_every_enum_value_in_the_schema_is_one_the_send_path_keeps(self): + """The schema and timeutil cannot be allowed to drift apart.""" + from palmer.timeutil import RECURRENCES + from palmer.tools_def import TOOLS + schema = next(t for t in TOOLS if t["name"] == "set_reminder") + enum = schema["input_schema"]["properties"]["recurrence"]["enum"] + assert set(enum) == set(RECURRENCES) + + +# ============================================================================ +# from test_clock_correctness.py +# ============================================================================ +# +# The remaining places a date was computed on the wrong calendar. +# +# Each of these answered confidently and was wrong by a day, which is worse than +# failing: the output names the date, so the user reads a specific claim that +# does not match reality. + +class TestTheMarketDayIsNewYorks: + def test_the_exchange_timezone_is_pinned(self): + """Not the server's day, and deliberately not the reader's either — a + session closes when New York says it does, whoever is asking.""" + assert datafeeds._MARKET_TZ == "America/New_York" + + def test_after_the_utc_rollover_it_is_still_the_same_trading_day(self): + # 00:30Z on Aug 31 is 20:30 ET on Aug 30 — the afternoon's close is + # TODAY's, and the old date.today() labelled it "yesterday". + instant = datetime(2026, 8, 31, 0, 30, tzinfo=timezone.utc) + with patch("palmer.timeutil.datetime") as dt: + dt.now.side_effect = lambda tz=None: instant.astimezone(tz) if tz else instant + assert timeutil.local_today(datafeeds._MARKET_TZ) == date(2026, 8, 30) + assert timeutil.local_today(None) == date(2026, 8, 31) + + +class TestWeatherDayResolution: + """The convention lives in timeutil now, not weather. + + It is generic date reasoning, and leaving it in the weather module was + how the reminder path — the one place the model computes a date itself — + ended up with no answer for "next friday" at all, while weather had a + considered one.""" + + def _delta(self, when, on): + with patch.object(timeutil, "local_today", return_value=on): + return timeutil.resolve_day_delta(when, when.lower()) + + FRIDAY = date(2026, 8, 28) + WEDNESDAY = date(2026, 8, 26) + + def test_tomorrow_is_one_day(self): + assert self._delta("tomorrow", self.FRIDAY) == 1 + + def test_a_bare_weekday_is_the_next_one(self): + assert self._delta("friday", self.WEDNESDAY) == 2 + + def test_next_weekday_is_a_week_past_that(self): + """"friday" and "next friday" used to be indistinguishable, so someone + planning a week out got this week's forecast under next week's name.""" + assert self._delta("next friday", self.WEDNESDAY) == 9 + + def test_the_two_rules_compose_rather_than_stacking(self): + """A bare weekday naming TODAY resolves a week out (an existing, tested + decision), so a flat +7 on top would put "next friday" a fortnight away.""" + assert self._delta("friday", self.FRIDAY) == 7 + assert self._delta("next friday", self.FRIDAY) == 7 + + def test_an_explicit_date_still_wins(self): + assert self._delta("2026-08-30", self.FRIDAY) == 2 + + def test_a_misspelt_weekday_still_resolves(self): + """Substring matching is doing real work here: "thursdayish" is still a + Thursday, and "next" still bumps it.""" + assert self._delta("next thursdayish", self.FRIDAY) == 13 + + def test_unparseable_input_returns_none(self): + """The caller then answers for TODAY and logs it. It used to silently + become tomorrow, so an unreadable phrase was answered for a day the user + never named — and the output states that date as fact.""" + for phrase in ("sometime soon", "when it cools off", "later"): + assert self._delta(phrase, self.FRIDAY) is None, phrase + + def test_the_callers_default_to_today_not_tomorrow(self): + import inspect + src = inspect.getsource(weather) + assert "delta = 1" not in src, "unparseable input must not mean tomorrow" + + def test_the_target_is_anchored_on_the_readers_calendar(self): + import inspect + src = inspect.getsource(weather._nws_report) + # delta is computed in the user's zone, so it must be added to the + # user's today — not to the forecast grid's, which is a different place. + assert "_lt(tz) if valid_zone(tz)" in src + + +class TestTimezoneIsValidatedAndRepaired: + def _apply(self, profile, updates, derived=None): + with patch.object(userprofile, "upsert_profile") as up, \ + patch.object(userprofile, "get_profile", return_value=profile), \ + patch.object(userprofile, "_derive_timezone", return_value=derived), \ + patch.object(userprofile, "_eager_build_home"): + userprofile._apply_profile_updates("+15550001111", profile, updates) + return up.call_args[0][1] if up.call_args else {} + + def test_junk_from_the_extractor_is_dropped(self): + """`timezone` is in EXTRACT_PROMPT's schema, so Haiku can write anything. + An unresolvable value degrades every local_now call to UTC, silently.""" + written = self._apply({"city": "Chicago"}, {"timezone": "Pacific Time"}) + assert "timezone" not in written + + def test_a_real_zone_is_kept(self): + written = self._apply({"city": "Chicago"}, {"timezone": "America/Denver"}) + assert written["timezone"] == "America/Denver" + + def test_a_move_re_derives_the_zone(self): + """It used to derive only when ABSENT, so someone who moved kept the old + zone forever and their morning arrived at the wrong hour from then on.""" + written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, + {"city": "Los Angeles"}, + derived="America/Los_Angeles") + assert written["timezone"] == "America/Los_Angeles" + + def test_no_move_leaves_it_alone(self): + written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, + {"vibe": "cheerful"}, derived="America/Denver") + assert "timezone" not in written + + def test_an_explicit_timezone_update_is_not_overridden_by_the_city(self): + written = self._apply({"city": "Chicago", "timezone": "America/Chicago"}, + {"city": "Denver", "timezone": "America/Denver"}, + derived="America/Boise") + assert written["timezone"] == "America/Denver" + + def test_the_forecast_correction_path_does_not_reach_this_function(self): + """CLAUDE.md: correcting a weather city must not move the hour the + morning arrives. That write calls upsert_profile directly.""" + import inspect + from palmer import agent + src = inspect.getsource(agent.get_reply) + assert "_city_from_weather_topic" in src + assert "_apply_profile_updates" not in src + + +class TestConsolidationDoesNotRunEveryTurn: + def test_the_gate_exists(self): + assert userprofile.CONSOLIDATE_EVERY >= 10 + + def test_it_is_skipped_until_enough_new_messages(self): + with patch.object(userprofile, "get_message_count", return_value=45), \ + patch.object(userprofile, "get_profile", + return_value={"consolidated_at_count": 40}), \ + patch.object(userprofile, "get_older_messages") as older: + userprofile._consolidate_history("+15550001111") + older.assert_not_called() + + def test_it_runs_once_the_gap_is_wide_enough(self): + with patch.object(userprofile, "get_message_count", return_value=65), \ + patch.object(userprofile, "get_profile", + return_value={"consolidated_at_count": 40}), \ + patch.object(userprofile, "get_older_messages", return_value=[]) as older: + userprofile._consolidate_history("+15550001111") + older.assert_called_once() + + def test_the_watermark_is_bookkeeping_not_an_extraction_field(self): + from palmer import prompts + assert "consolidated_at_count" in userprofile.PROFILE_FIELDS + assert "consolidated_at_count" not in prompts.EXTRACT_PROMPT + + +class TestCurationIsToldTheReadersDate: + def test_curate_takes_a_date(self): + import inspect + sig = inspect.signature(importlib.import_module("palmer.opening")._curate) + assert "today" in sig.parameters + + def test_the_snapshot_passes_the_local_day(self): + import inspect + from palmer import opening + src = inspect.getsource(opening.opening_snapshot) + assert "_curate(metro or _metro(city), pool, today=today)" in src diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py deleted file mode 100644 index 354df1f..0000000 --- a/tests/test_timeutil.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for user-local date/time helpers. - -Palmer's dyno runs UTC but almost everything user-facing needs the user's -local calendar day. These tests lock in the semantics (real tz honored, no-tz -falls back to UTC, unknown tz doesn't raise) and prove the late-night west- -coast off-by-one is actually fixed. -""" -from datetime import date, datetime, timezone -from unittest.mock import patch - -from palmer import timeutil - - -class TestLocalNow: - def test_returns_tz_aware_datetime_for_valid_tz(self): - out = timeutil.local_now("America/Los_Angeles") - assert out.tzinfo is not None - # tz name should be present on the returned aware dt - assert "Los_Angeles" in str(out.tzinfo) - - def test_none_tz_falls_back_to_utc(self): - out = timeutil.local_now(None) - assert out.tzinfo == timezone.utc - - def test_empty_tz_falls_back_to_utc(self): - assert timeutil.local_now("").tzinfo == timezone.utc - - def test_bogus_tz_falls_back_to_utc_without_raising(self): - assert timeutil.local_now("Not/A/Real_Zone").tzinfo == timezone.utc - - -class TestLocalToday: - def test_returns_date_type(self): - assert isinstance(timeutil.local_today("America/Chicago"), date) - - def test_none_tz_uses_utc_date(self): - # Freeze datetime.now so we can compare exactly - with patch("palmer.timeutil.datetime") as mock_dt: - mock_dt.now.side_effect = lambda tz=None: datetime(2026, 8, 18, 20, 0, tzinfo=tz or timezone.utc) - assert timeutil.local_today(None) == date(2026, 8, 18) - - def test_west_coast_late_evening_stays_on_yesterday_local(self): - """The bug this whole exercise was for: at 22:00 America/Los_Angeles - on Aug 17 the server is already Aug 18 UTC, so anything using - date.today() sees Aug 18. local_today('America/Los_Angeles') must - still see Aug 17.""" - # Fake datetime.now so it returns Aug 18 05:00 UTC (=Aug 17 22:00 PDT) - with patch("palmer.timeutil.datetime") as mock_dt: - def _now(tz=None): - base = datetime(2026, 8, 18, 5, 0, tzinfo=timezone.utc) - return base.astimezone(tz) if tz else base - mock_dt.now.side_effect = _now - assert timeutil.local_today("America/Los_Angeles") == date(2026, 8, 17) - assert timeutil.local_today(None) == date(2026, 8, 18) # UTC still Aug 18 - - -class TestResolveDayDeltaHonorsTz: - """The end-to-end bug: at 22:00 PDT Thursday, someone asking 'weather - Friday' should get delta=1 (tomorrow), not delta=8 (next Friday).""" - - def test_friday_at_thursday_night_local_is_tomorrow(self): - from palmer.timeutil import resolve_day_delta - # Freeze both timeutil.local_today and weather._date.today so the module - # under test sees a consistent 'now'. - with patch("palmer.timeutil.datetime") as mock_dt: - def _now(tz=None): - # Aug 21 2026 is a Friday. So 05:00 UTC on Aug 21 = 22:00 PDT on Aug 20 (Thursday). - base = datetime(2026, 8, 21, 5, 0, tzinfo=timezone.utc) - return base.astimezone(tz) if tz else base - mock_dt.now.side_effect = _now - - # UTC-only path (no tz): 'friday' seen from Friday should give 7 (next friday) - assert resolve_day_delta("friday", "friday", tz=None) == 7 - # With user tz PDT (still Thursday locally): 'friday' should give 1 (tomorrow) - assert resolve_day_delta("friday", "friday", tz="America/Los_Angeles") == 1 - - def test_tomorrow_always_one_day_out(self): - from palmer.timeutil import resolve_day_delta - assert resolve_day_delta("tomorrow", "tomorrow", tz="America/Chicago") == 1 - assert resolve_day_delta("tomorrow", "tomorrow", tz=None) == 1 diff --git a/tests/test_trends.py b/tests/test_trends.py deleted file mode 100644 index 68dfa68..0000000 --- a/tests/test_trends.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for the adjacent-story pick. - -This is the one part of the briefing that isn't something the user asked for, -so the guards matter more than the feature: it must never invent a trend, never -repeat what the briefing already covers, and must fail to silence rather than to -filler. Haiku is mocked — its judgement quality is checked live, not here. -""" -from datetime import date -from unittest.mock import patch, MagicMock - -from palmer import trends - - -def _reply(text: str) -> MagicMock: - b = MagicMock(); b.text = text - r = MagicMock(); r.content = [b] - return r - - -CANDIDATES = [ - {"query": "jeff bezos zero income tax idea", "volume": 200000, "categories": ["Business"]}, - {"query": "espanyol vs real madrid", "volume": 500000, "categories": ["Sports"]}, -] -INTERESTS = ["Bitcoin and stocks", "SpaceX"] - - -def _patch_candidates(): - return patch.object(trends, "trending_now", return_value=list(CANDIDATES)) - - -class TestTrendingFetch: - def test_filters_low_volume_and_sorts_by_volume(self): - payload = {"trending_searches": [ - {"query": "tiny", "search_volume": 10, "categories": []}, - {"query": "big", "search_volume": 500000, "categories": [{"name": "News"}]}, - {"query": "mid", "search_volume": 50000, "categories": []}, - ]} - trends._cache.clear() - with patch.object(trends.serpapi, "search", return_value=payload): - out = trends.trending_now("US", date(2026, 8, 23)) - assert [i["query"] for i in out] == ["big", "mid"], "low-volume noise must be dropped" - - def test_cached_per_geo_and_day(self): - """Trending is identical for everyone — one fetch should serve the run.""" - trends._cache.clear() - payload = {"trending_searches": [{"query": "x", "search_volume": 100000, "categories": []}]} - with patch.object(trends.serpapi, "search", return_value=payload) as api: - trends.trending_now("US", date(2026, 8, 23)) - trends.trending_now("US", date(2026, 8, 23)) - assert api.call_count == 1 - - def test_new_day_refetches(self): - trends._cache.clear() - payload = {"trending_searches": [{"query": "x", "search_volume": 100000, "categories": []}]} - with patch.object(trends.serpapi, "search", return_value=payload) as api: - trends.trending_now("US", date(2026, 8, 23)) - trends.trending_now("US", date(2026, 8, 24)) - assert api.call_count == 2 - - def test_api_failure_returns_empty(self): - trends._cache.clear() - with patch.object(trends.serpapi, "search", side_effect=RuntimeError("boom")): - assert trends.trending_now("US", date(2026, 8, 23)) == [] - - -class TestAdjacentPick: - def test_returns_story_for_a_valid_pick(self): - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", - return_value=_reply('{"query": "jeff bezos zero income tax idea", "why": "markets angle"}')), \ - patch.object(trends, "_search_raw", - return_value=[{"title": "Bezos floats plan", "content": "details"}]): - out = trends.adjacent_story(INTERESTS, ["Bitcoin flat"]) - assert out["query"] == "jeff bezos zero income tax idea" - assert "Bezos floats plan" in out["story"] - - def test_none_verdict_is_respected(self): - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", return_value=_reply('{"query": "NONE"}')), \ - patch.object(trends, "_search_raw") as search: - assert trends.adjacent_story(INTERESTS, []) is None - search.assert_not_called() - - def test_invented_trend_is_rejected(self): - """A hallucinated trend is exactly the filler this feature must not add.""" - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", - return_value=_reply('{"query": "aliens land in ohio", "why": "big if true"}')), \ - patch.object(trends, "_search_raw") as search: - assert trends.adjacent_story(INTERESTS, []) is None - search.assert_not_called() - - def test_pick_with_no_story_behind_it_is_dropped(self): - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", - return_value=_reply('{"query": "jeff bezos zero income tax idea", "why": "x"}')), \ - patch.object(trends, "_search_raw", return_value=[]): - assert trends.adjacent_story(INTERESTS, []) is None - - def test_covered_headlines_reach_the_prompt(self): - captured = {} - - def _create(**kw): - captured["p"] = kw["messages"][0]["content"] - return _reply('{"query": "NONE"}') - - with _patch_candidates(), patch.object(trends.client.messages, "create", side_effect=_create): - trends.adjacent_story(INTERESTS, ["Bitcoin flat at 77k"]) - assert "Bitcoin flat at 77k" in captured["p"], "the pick must know what's already covered" - - def test_no_interests_means_no_pick(self): - with patch.object(trends, "trending_now") as t: - assert trends.adjacent_story([], []) is None - t.assert_not_called() - - def test_model_failure_is_silent(self): - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", side_effect=RuntimeError("boom")): - assert trends.adjacent_story(INTERESTS, []) is None - - def test_unparseable_reply_is_silent(self): - with _patch_candidates(), \ - patch.object(trends.client.messages, "create", return_value=_reply("not json")): - assert trends.adjacent_story(INTERESTS, []) is None - - -class TestBriefingIntegration: - def test_adjacent_failure_does_not_break_the_briefing(self): - from palmer import morning - with patch.object(morning, "_weather_report", return_value="warm"), \ - patch.object(morning, "_topic_digest", return_value="story"), \ - patch.object(morning, "get_city_traffic", return_value="clear"), \ - patch.object(morning, "get_travel_time", return_value="17 min"), \ - patch("palmer.trends.adjacent_story", side_effect=RuntimeError("boom")): - out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) - assert any("story" in s for s in out), "topics must survive a trends failure" - - def test_adjacent_section_is_labelled_for_the_drafter(self): - from palmer import morning - with patch.object(morning, "_weather_report", return_value="warm"), \ - patch.object(morning, "_topic_digest", return_value="story"), \ - patch.object(morning, "get_city_traffic", return_value="clear"), \ - patch.object(morning, "get_travel_time", return_value="17 min"), \ - patch("palmer.trends.adjacent_story", - return_value={"query": "q", "why": "close to markets", "story": "Big thing happened"}): - out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) - adj = [s for s in out if s.startswith("ADJACENT")] - assert adj and "close to markets" in adj[0] and "Big thing happened" in adj[0] diff --git a/tests/test_user_covered.py b/tests/test_user_covered.py deleted file mode 100644 index 810f8f8..0000000 --- a/tests/test_user_covered.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tests for _user_already_covered — suppresses proactive sends when the user -already brought up the same story themselves in their recent messages.""" -from unittest.mock import patch, MagicMock - -from palmer import userprofile - - -def _haiku_reply(text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - -class TestUserAlreadyCovered: - def test_no_recent_user_messages_returns_false_without_haiku(self): - with patch("palmer.db.get_recent_user_messages", return_value=[]), \ - patch("palmer.userprofile.client") as mock_client: - assert userprofile._user_already_covered("+15550000000", "Iran launched strikes") is False - mock_client.messages.create.assert_not_called() - - def test_yes_reply_suppresses(self): - with patch("palmer.db.get_recent_user_messages", return_value=[ - "did you see the Iran thing?", - "wild what's happening over there", - ]), patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("YES") - assert userprofile._user_already_covered( - "+15550000000", - "Iran launched missiles at a US base in Iraq." - ) is True - - def test_no_reply_allows_send(self): - with patch("palmer.db.get_recent_user_messages", return_value=[ - "grocery list: milk, bread", - "what's the weather tomorrow?", - ]), patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("NO") - assert userprofile._user_already_covered( - "+15550000000", - "Cardinals move into first place with 4-2 win." - ) is False - - def test_haiku_failure_fails_open(self): - """Fail-open semantics — a broken Haiku call must NOT silently suppress - real alerts. Better to send a possibly-duplicate than to drop a real one.""" - with patch("palmer.db.get_recent_user_messages", return_value=["did you see that"]), \ - patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.side_effect = RuntimeError("boom") - assert userprofile._user_already_covered("+15550000000", "some alert") is False - - def test_prompt_shape_includes_recent_and_candidate(self): - captured = [] - - def _create(**kwargs): - captured.append(kwargs["messages"][0]["content"]) - return _haiku_reply("NO") - - with patch("palmer.db.get_recent_user_messages", - return_value=["did you see the Iran thing", "crazy"]), \ - patch("palmer.userprofile.client") as mock_client: - mock_client.messages.create.side_effect = _create - userprofile._user_already_covered("+15550000000", "Iran launched missiles.") - prompt = captured[0] - assert "Iran launched missiles" in prompt - assert "Iran thing" in prompt - # Prompt should distinguish specific-event awareness from general-topic mention - low = prompt.lower() - assert "did you see" in low or "have you heard" in low - assert "general topic" in low or "background chatter" in low - - def test_window_hours_default_is_12(self): - """The 12h default is intentional — user mentioning a story in the morning - should suppress an afternoon alert on the same story.""" - # Check the signature default without invoking Haiku - import inspect - sig = inspect.signature(userprofile._user_already_covered) - assert sig.parameters["window_hours"].default == 12 diff --git a/tests/test_userprofile.py b/tests/test_userprofile.py new file mode 100644 index 0000000..c010455 --- /dev/null +++ b/tests/test_userprofile.py @@ -0,0 +1,807 @@ +"""The profile: schema, freshness, overlap, onboarding. + +Merged from test_profile_schema.py, test_profile_freshness.py, test_user_covered.py, test_onboard.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" +import pytest +from unittest.mock import patch, MagicMock +from palmer import db, userprofile, agent, artifacts, home, onboard +from scripts import migrate_profile_prune as mig +from datetime import date, timedelta +from palmer.timeutil import local_today +from tests.helpers import llm_reply + + +# ============================================================================ +# from test_profile_schema.py +# ============================================================================ +# +# Tests for the profile allow-list and the pruning migration. +# +# The extractor is a language model writing straight into a dict that gets dumped +# as JSON into every system prompt. Unbounded, one profile reached 624 keys — 604 +# invented one-offs, ~21,700 tokens of noise per message. These pin the bound. + +class TestAllowList: + def test_known_fields_survive(self): + out = userprofile._canonical_updates({"city": "Kirkwood", "interests": ["golf"]}) + assert out == {"city": "Kirkwood", "interests": ["golf"]} + + def test_invented_fields_are_dropped(self): + out = userprofile._canonical_updates({ + "city": "Kirkwood", "monday_night_behavior": "watches film", + "kendrick_fan": True, "alternatively": "x", + }) + assert out == {"city": "Kirkwood"} + + def test_aliases_still_map_and_retire(self): + out = userprofile._canonical_updates({"location": "Kirkwood"}) + assert out["city"] == "Kirkwood" + assert out["location"] is None, "the alias must be cleared, not left behind" + + def test_every_field_the_code_reads_is_allowed(self): + """A key missing from PROFILE_FIELDS would be silently discarded on write.""" + for field in ("city", "timezone", "morning_topics", "morning_time", + "morning_onboarded", "morning_prefs", "morning_sent_date", + "commute", "communication_style", "reactions", + "reactions_folded_count", "conversation_topics", + "pending_morning_suggestion", "pending_preference_notice", + "followup_sent_date", "intro_sent", + "ongoing_threads", "life_context", + "onboarding_ask_sent", "score_offer_sent", "followed_teams"): + assert field in userprofile.PROFILE_FIELDS, field + + +class TestPrune: + def test_splits_kept_from_dropped(self): + kept, dropped = userprofile.prune_profile({"city": "K", "kendrick_fan": True}) + assert kept == {"city": "K"} and dropped == ["kendrick_fan"] + + def test_pure(self): + profile = {"city": "K", "junk": 1} + userprofile.prune_profile(profile) + assert "junk" in profile, "prune must not mutate its input" + + def test_empty(self): + assert userprofile.prune_profile({}) == ({}, []) + + +class TestNoneDeletes: + """A stored null still costs prompt tokens. None must remove the key.""" + + def test_none_removes_the_key(self, fresh_db): + with patch.object(db, "_conn", db._conn): + db.init_db() + db.upsert_profile("+1555", {"city": "Kirkwood", "vibe": "dry"}) + db.upsert_profile("+1555", {"vibe": None}) + prof = db.get_profile("+1555") + assert prof["city"] == "Kirkwood" + assert "vibe" not in prof, "None should delete, not store a null" + + def test_deleting_an_absent_key_is_a_noop(self, fresh_db): + db.upsert_profile("+1556", {"city": "K"}) + db.upsert_profile("+1556", {"never_set": None}) + assert db.get_profile("+1556")["city"] == "K" + + +class TestTopicCleanup: + ROUTE = ("Daily commute traffic: 33 Cedarbrook Lane, Kirkwood MO 63122 " + "to 190 Carondelet Plaza, Clayton MO 63105") + FORMAT = "Format: bullet points per subject, not one continuous paragraph" + + def test_directive_and_route_leave_the_topic_list(self): + out = mig._clean_topics({"morning_topics": ["SpaceX news", self.FORMAT, self.ROUTE]}) + assert out["morning_topics"] == ["SpaceX news"] + + def test_route_is_promoted_not_discarded(self): + out = mig._clean_topics({"morning_topics": [self.ROUTE]}) + assert out["commute"] == { + "origin": "33 Cedarbrook Lane, Kirkwood MO 63122", + "destination": "190 Carondelet Plaza, Clayton MO 63105", + } + + def test_existing_commute_is_not_overwritten(self): + existing = {"origin": "A Street, Town", "destination": "B Street, City"} + out = mig._clean_topics({"commute": existing, "morning_topics": [self.ROUTE]}) + assert "commute" not in out + assert out["morning_topics"] == [] + + def test_clean_list_is_left_alone(self): + assert mig._clean_topics({"morning_topics": ["SpaceX news", "LA weather"]}) == {} + + def test_no_topics(self): + assert mig._clean_topics({}) == {} + + +class TestConsolidationSafety: + def test_only_merge_targets_are_accepted(self): + """The fold must never move a briefing time or clear a send guard.""" + from unittest.mock import MagicMock + b = MagicMock(); b.text = '{"interests": ["golf"], "morning_time": "03:00", "city": "Nowhere"}' + r = MagicMock(); r.content = [b] + with patch.object(mig.client.messages, "create", return_value=r): + out = mig._consolidate({}, {"junk": 1}) + assert out == {"interests": ["golf"]} + assert "morning_time" not in out and "city" not in out + + def test_failure_prunes_without_merging(self): + with patch.object(mig.client.messages, "create", side_effect=RuntimeError("boom")): + assert mig._consolidate({}, {"junk": 1}) == {} + + def test_nothing_stray_means_no_api_call(self): + with patch.object(mig.client.messages, "create") as create: + assert mig._consolidate({"city": "K"}, {}) == {} + create.assert_not_called() + + +class TestNameIsExtracted: + """"My name is Jeff" returned {} from the extractor, so profile["name"] + stayed empty while Palmer happily called the user Jeff from conversation + history. The page reads the profile, so it showed "Your briefing" and kept + asking for a name it had already been told twice.""" + + def test_the_schema_demands_identity_explicitly(self): + from palmer.prompts import EXTRACT_PROMPT + assert "IDENTITY FIRST" in EXTRACT_PROMPT + + def test_it_names_the_phrasings_people_actually_use(self): + from palmer.prompts import EXTRACT_PROMPT + low = EXTRACT_PROMPT.lower() + for phrase in ("my name is", "i'm jeff", "call me"): + assert phrase in low + + def test_it_overrides_the_skip_when_already_present_instinct(self): + """The failure was the model deciding the name was too obvious to + bother returning, or assuming it must already be stored.""" + from palmer.prompts import EXTRACT_PROMPT + low = EXTRACT_PROMPT.lower() + assert "even when" in low and "already" in low + + def test_name_is_still_an_allowed_field(self): + from palmer.userprofile import PROFILE_FIELDS + assert "name" in PROFILE_FIELDS + + +class TestCityPrecisionIsExtracted: + """A generic city mention ("LA traffic today") was overwriting a specific, + correct one ("Culver City, CA") already on file, because EXTRACT_PROMPT had + no rule distinguishing "where they live" from "a place they mentioned".""" + + def test_the_schema_has_a_location_precision_rule(self): + from palmer.prompts import EXTRACT_PROMPT + assert "LOCATION PRECISION" in EXTRACT_PROMPT + + def test_it_excludes_passing_mentions(self): + from palmer.prompts import EXTRACT_PROMPT + low = EXTRACT_PROMPT.lower() + for phrase in ("in passing", "traffic into la", "leave the existing value alone"): + assert phrase in low + + def test_consolidate_prompt_also_guards_city(self): + from palmer.prompts import CONSOLIDATE_PROMPT + low = CONSOLIDATE_PROMPT.lower() + assert '"city"' in low and "leave it unchanged" in low + + +class TestCityChangeIsLogged: + def test_city_regression_prints_old_and_new(self, fresh_db, monkeypatch, capsys): + monkeypatch.setenv("APP_URL", "https://palmer.test") + db.upsert_profile("+1557", {"city": "Culver City, CA"}) + profile = db.get_profile("+1557") + with patch("palmer.home.rebuild") as rebuild: + userprofile._apply_profile_updates("+1557", profile, {"city": "Los Angeles"}) + out = capsys.readouterr().out + assert "Culver City, CA" in out and "Los Angeles" in out + rebuild.assert_not_called(), "a correction to an existing city is not a day-1 build" + + +class TestEagerHomeBuild: + """The first time a user's city becomes known, Palmer Home gets built right + away rather than waiting for get_my_page or the morning job — see CLAUDE.md + "Onboarding" / the day-1 site build. It never sends the link; that's still + gated on an explicit ask or the first morning send, unchanged.""" + + def test_first_city_triggers_a_build(self, fresh_db, monkeypatch): + monkeypatch.setenv("APP_URL", "https://palmer.test") + db.upsert_profile("+1558", {}) + profile = db.get_profile("+1558") + with patch("palmer.home.home_token", return_value="tok"), \ + patch("palmer.home.load", return_value=None), \ + patch("palmer.home.rebuild") as rebuild: + userprofile._apply_profile_updates("+1558", profile, {"city": "Chicago"}) + rebuild.assert_called_once_with("+1558", refresh_news=True) + + def test_city_correction_does_not_rebuild(self, fresh_db, monkeypatch): + monkeypatch.setenv("APP_URL", "https://palmer.test") + db.upsert_profile("+1559", {"city": "Culver City, CA"}) + profile = db.get_profile("+1559") + with patch("palmer.home.rebuild") as rebuild: + userprofile._apply_profile_updates("+1559", profile, {"city": "Los Angeles"}) + rebuild.assert_not_called() + + def test_no_app_url_skips_the_build(self, fresh_db, monkeypatch): + monkeypatch.delenv("APP_URL", raising=False) + db.upsert_profile("+1560", {}) + profile = db.get_profile("+1560") + with patch("palmer.home.rebuild") as rebuild: + userprofile._apply_profile_updates("+1560", profile, {"city": "Chicago"}) + rebuild.assert_not_called() + + def test_a_page_already_built_is_not_rebuilt_again(self, fresh_db, monkeypatch): + monkeypatch.setenv("APP_URL", "https://palmer.test") + db.upsert_profile("+1561", {}) + profile = db.get_profile("+1561") + with patch("palmer.home.home_token", return_value="tok"), \ + patch("palmer.home.load", return_value={"city": "Chicago"}), \ + patch("palmer.home.rebuild") as rebuild: + userprofile._apply_profile_updates("+1561", profile, {"city": "Chicago"}) + rebuild.assert_not_called() + + +class TestOnboardingAskConsumption: + """The ONBOARDING ASK block in _build_system (agent.py) fires under this + exact condition; _update_profile marks it consumed the first time it sees + that same condition hold true after a turn's extraction runs.""" + + def test_marks_consumed_after_a_qualifying_turn(self, fresh_db): + db.upsert_profile("+1562", {"intro_sent": True}) + block = MagicMock(); block.text = "{}" + resp = MagicMock(); resp.content = [block] + with patch.object(userprofile.client.messages, "create", return_value=resp): + userprofile._update_profile("+1562", "hey", "hey, how's it going") + assert db.get_profile("+1562")["onboarding_ask_sent"] is True + + def test_not_marked_when_the_turn_supplies_both_fields(self, fresh_db, monkeypatch): + monkeypatch.delenv("APP_URL", raising=False) + db.upsert_profile("+1563", {"intro_sent": True}) + block = MagicMock(); block.text = '{"name": "Ada", "city": "Chicago"}' + resp = MagicMock(); resp.content = [block] + with patch.object(userprofile.client.messages, "create", return_value=resp): + userprofile._update_profile("+1563", "I'm Ada from Chicago", "hey Ada") + assert "onboarding_ask_sent" not in db.get_profile("+1563") + + def test_not_marked_before_intro_is_sent(self, fresh_db): + db.upsert_profile("+1564", {}) + block = MagicMock(); block.text = "{}" + resp = MagicMock(); resp.content = [block] + with patch.object(userprofile.client.messages, "create", return_value=resp): + userprofile._update_profile("+1564", "hey", "hey") + assert "onboarding_ask_sent" not in db.get_profile("+1564") + + +class TestNoFieldIsAlsoAnAlias: + """`teams` shipped as a real PROFILE_FIELD while `_PROFILE_ALIASES` still + mapped it to `sports_teams`. `_normalize_profile` runs on every inbound + message, so `follow_team` stored a follow list, Palmer confirmed it, and the + next message migrated it into `sports_teams` and wrote `teams: None` — + the follow silently gone, its games out of the morning, and the interest collector then + raising AttributeError on a dict from a call site outside a try. + """ + + def test_an_alias_key_is_never_a_real_field(self): + from palmer import userprofile as up + clashes = {k: v for k, v in up._PROFILE_ALIASES.items() if k in up.PROFILE_FIELDS} + assert not clashes, f"these fields are silently rewritten on write: {clashes}" + + def test_the_structured_follow_lists_survive_a_normalise(self): + from palmer import userprofile as up + for field in ("followed_teams", "shows"): + row = [{"league": "nfl", "abbrev": "PHI", "name": "Philadelphia Eagles"}] + out = up._canonical_updates({field: row}) + assert out.get(field) == row, f"{field} did not survive canonicalisation" + + def test_the_tool_written_lists_are_not_in_the_extractor_schema(self): + """They hold structured dicts written by tool dispatch. In the schema, + Haiku fills them with prose and downstream code gets strings.""" + from palmer import prompts + for field in ("followed_teams", "shows", "weather_locations"): + assert f'"{field}"' not in prompts.EXTRACT_PROMPT + + +# ============================================================================ +# from test_profile_freshness.py +# ============================================================================ +# +# Stale facts, and the three places they leaked into what Palmer says. +# +# The whole profile is dumped into every system prompt as CURRENT fact and +# nothing in it ever expired. One profile read `city: "Culver City"` three lines +# above `life_context: "Based in LA"` — both true when written, and together the +# exact contradiction behind a week of Los Angeles temperatures arriving under +# the name Culver City. Another still carried `stressed_about: "active fire +# emergency in LA area"` long after the fire, and two carried a `follow_up` full +# of notes about Palmer's own delivery rather than anything about the person. +# +# None of that was the model being confused by volume — the system prompt and +# tools are about 13k tokens and the profile 1-3k, which is comfortable. It was +# the model being told, every turn, things that had stopped being true. +# +# Three mechanisms here: facts age and then disappear, `city` is declared +# authoritative over anything else that names a place, and the card reads the +# user's clock rather than the dyno's. + +def _aged(field, days, value="something"): + return {"city": "Culver City", "timezone": "America/Los_Angeles", field: value, + "field_dates": {field: (date.today() - timedelta(days=days)).isoformat()}} + + +class TestVolatileFactsAge: + def test_a_fresh_fact_is_shown_plainly(self): + out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", 0)) + assert out["stressed_about"] == "something" + + def test_a_few_days_old_carries_its_date(self): + """The model should be able to tell a live worry from a week-old one.""" + out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", 6)) + assert out["stressed_about"]["value"] == "something" + assert out["stressed_about"]["days_old"] == 6 + + def test_past_its_life_it_disappears(self): + life = userprofile.VOLATILE_FIELDS["stressed_about"] + out = userprofile.fresh_profile_for_prompt(_aged("stressed_about", life + 1)) + assert "stressed_about" not in out + + def test_durable_facts_never_expire(self): + """Name, city, job and relationships do not rot, and dating them would + invite the model to doubt things it should not.""" + old = {"name": "Sam", "city": "Springfield", "job": "teacher", + "field_dates": {"name": "2020-01-01"}} + out = userprofile.fresh_profile_for_prompt(old) + assert out["name"] == "Sam" and out["city"] == "Springfield" + for f in ("name", "city", "job"): + assert f not in userprofile.VOLATILE_FIELDS + + def test_an_unstamped_fact_is_left_alone(self): + """Profiles written before stamping existed must not vanish wholesale.""" + out = userprofile.fresh_profile_for_prompt({"stressed_about": "x", "city": "Y"}) + assert out["stressed_about"] == "x" + + def test_storage_is_untouched(self): + """A fact that went quiet was not wrong — the consolidator may reassert + it tomorrow, so nothing is deleted from the row.""" + p = _aged("stressed_about", 999) + userprofile.fresh_profile_for_prompt(p) + assert p["stressed_about"] == "something" + + def test_writing_a_volatile_field_stamps_it(self): + updates = {"stressed_about": "a deadline"} + userprofile._stamp_volatile({"timezone": "America/Chicago"}, updates) + assert updates["field_dates"]["stressed_about"] == \ + local_today("America/Chicago").isoformat() + + def test_writing_a_durable_field_stamps_nothing(self): + updates = {"name": "Jeff"} + userprofile._stamp_volatile({}, updates) + assert "field_dates" not in updates + + def test_clearing_a_field_does_not_restamp_it(self): + updates = {"stressed_about": None} + userprofile._stamp_volatile({}, updates) + assert "field_dates" not in updates + + +class TestCityIsAuthoritative: + """`city` is the only location any tool reads. Everything else that names a + place is background and may be months out of date.""" + + def test_the_prompt_names_the_city_and_ranks_it(self): + profile = {"city": "Culver City", "life_context": "Based in LA."} + with patch.object(agent, "get_profile", return_value=profile): + sys = agent._build_system("+1555") + assert "Their location is Culver City, full stop" in sys + assert "never pair a number with a place it did not come from" in sys + + def test_no_city_means_no_claim(self): + with patch.object(agent, "get_profile", return_value={"name": "Jeff"}): + sys = agent._build_system("+1555") + assert "full stop" not in sys + + def test_stale_context_is_filtered_before_the_model_sees_it(self): + life = userprofile.VOLATILE_FIELDS["life_context"] + profile = {"city": "Culver City", "timezone": "America/Los_Angeles", + "life_context": "Based in LA.", + "field_dates": {"life_context": (local_today("America/Los_Angeles") + - timedelta(days=life + 1)).isoformat()}} + with patch.object(agent, "get_profile", return_value=profile): + sys = agent._build_system("+1555") + assert "Based in LA" not in sys + + +class TestEmptySectionsDoNotLockForTheFullWindow: + """The `_tried` stamp is written before the call so a failure cannot loop. + That also meant one empty fetch left a section blank for its whole window — + it locked three of four users out of Opening for a day, twice.""" + + def test_a_section_holding_data_waits_the_full_window(self): + assert home._window_for("opening", True) == home.STALE["opening"] + + def test_an_empty_section_retries_sooner(self): + assert home._window_for("opening", False) < home.STALE["opening"] + assert home._window_for("headlines", False) < home.STALE["headlines"] + + def test_the_retry_is_bounded_not_a_loop(self): + for section in ("opening", "headlines"): + assert home._window_for(section, False) >= home.EMPTY_RETRY_FLOOR + + def test_a_never_on_view_section_stays_that_way(self): + with patch.dict(home.STALE, {"opening": None}): + assert home._window_for("opening", False) is None + + def test_the_gates_read_whether_data_exists(self): + import inspect + src = inspect.getsource(home.refresh_stale) + assert 'payload.get("opening")' in src and 'payload.get("headlines")' in src + + +class TestTheCardUsesTheReadersClock: + """page.py has always rendered the user's local day; cards.py defaulted to + datetime.now(), which is UTC on the dyno. From 5pm Pacific the card printed + tomorrow's date beside a page printing today's.""" + + def test_the_masthead_follows_the_profile_timezone(self): + la = artifacts._card_now({"timezone": "America/Los_Angeles"}) + chi = artifacts._card_now({"timezone": "America/Chicago"}) + assert la.utcoffset() != chi.utcoffset() + + def test_a_missing_timezone_asserts_no_day_at_all(self): + """It used to fall back to a naive datetime.now() — UTC on the dyno — + and print that unlabelled as the reader's day, which is the exact + thing clock_block was rewritten to stop doing. page.py omits the date + in this case, so the card has to as well or the two disagree.""" + assert artifacts._card_now({}) is None + assert artifacts._card_now({"timezone": "Not/AZone"}) is None + + def test_the_card_still_renders_without_a_day(self): + from palmer import cards + png = cards.render_dashboard(city="Austin", weather=None, traffic=None, + prices=None, headlines=None, show_date=False) + assert png[:4] == b"\x89PNG" + + def test_the_page_omits_it_too(self): + from palmer import page + assert page._local_day(None) == "" + assert page._local_day("Not/AZone") == "" + assert page._local_day("America/Chicago") != "" + + def test_the_renderer_is_told_the_time(self): + import inspect + src = inspect.getsource(artifacts.render_png) + assert "_card_now(payload)" in src + assert "show_date=" in src + + def test_the_cache_key_uses_the_readers_day(self): + """Otherwise the cached card outlives the reader's midnight.""" + import inspect + assert "_card_now(payload)" in inspect.getsource(artifacts._card_inputs) + + +class TestTheExtractorStopsRecordingItself: + def test_the_prompt_forbids_meta_facts(self): + """follow_up held "confirm_morning_briefing_delivery_is_consistent_daily" + for one user and "Maintain single-message format" for another — notes + about the product, read back as facts about a person.""" + from palmer import prompts + assert "never about Palmer's own operation" in prompts.EXTRACT_PROMPT + assert "return nothing for these fields" in prompts.EXTRACT_PROMPT + + +# ============================================================================ +# from test_user_covered.py +# ============================================================================ +# +# Tests for _user_already_covered — suppresses proactive sends when the user +# already brought up the same story themselves in their recent messages. + +class TestUserAlreadyCovered: + def test_no_recent_user_messages_returns_false_without_haiku(self): + with patch("palmer.db.get_recent_user_messages", return_value=[]), \ + patch("palmer.userprofile.client") as mock_client: + assert userprofile._user_already_covered("+15550000000", "Iran launched strikes") is False + mock_client.messages.create.assert_not_called() + + def test_yes_reply_suppresses(self): + with patch("palmer.db.get_recent_user_messages", return_value=[ + "did you see the Iran thing?", + "wild what's happening over there", + ]), patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("YES") + assert userprofile._user_already_covered( + "+15550000000", + "Iran launched missiles at a US base in Iraq." + ) is True + + def test_no_reply_allows_send(self): + with patch("palmer.db.get_recent_user_messages", return_value=[ + "grocery list: milk, bread", + "what's the weather tomorrow?", + ]), patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("NO") + assert userprofile._user_already_covered( + "+15550000000", + "Cardinals move into first place with 4-2 win." + ) is False + + def test_haiku_failure_fails_open(self): + """Fail-open semantics — a broken Haiku call must NOT silently suppress + real alerts. Better to send a possibly-duplicate than to drop a real one.""" + with patch("palmer.db.get_recent_user_messages", return_value=["did you see that"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.side_effect = RuntimeError("boom") + assert userprofile._user_already_covered("+15550000000", "some alert") is False + + def test_prompt_shape_includes_recent_and_candidate(self): + captured = [] + + def _create(**kwargs): + captured.append(kwargs["messages"][0]["content"]) + return llm_reply("NO") + + with patch("palmer.db.get_recent_user_messages", + return_value=["did you see the Iran thing", "crazy"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.side_effect = _create + userprofile._user_already_covered("+15550000000", "Iran launched missiles.") + prompt = captured[0] + assert "Iran launched missiles" in prompt + assert "Iran thing" in prompt + # Prompt should distinguish specific-event awareness from general-topic mention + low = prompt.lower() + assert "did you see" in low or "have you heard" in low + assert "general topic" in low or "background chatter" in low + + def test_window_hours_default_is_12(self): + """The 12h default is intentional — user mentioning a story in the morning + should suppress an afternoon alert on the same story.""" + # Check the signature default without invoking Haiku + import inspect + sig = inspect.signature(userprofile._user_already_covered) + assert sig.parameters["window_hours"].default == 12 + + +# ============================================================================ +# from test_onboard.py +# ============================================================================ +# +# Setup form: the link on message one, the one-shot write, and what it seeds. +# +# The regressions these hold are the ones the flow exists to fix — a city that +# lands too late to seed local news — plus the two rules that shape it: the link +# goes out at the setup moment through get_my_page, never on message one, and +# the box is free text so topics arrive subject-shaped. + +@pytest.fixture +def store(fresh_db, monkeypatch): + monkeypatch.setenv("APP_URL", "https://palmer.test") + return fresh_db + + +class _Form(dict): + """Starlette's FormData interface, as much of it as onboard.apply uses.""" + + +class TestStart: + def test_mints_a_token_and_parks_a_stub(self, store): + with patch("palmer.home.save") as save, patch("palmer.home.load", return_value=None): + url = onboard.start("+15550001111") + assert url.startswith("https://palmer.test/h/") + token, payload = save.call_args[0] + assert payload == {"phone": "+15550001111", "setup_pending": True} + assert token in url + + def test_no_app_url_means_no_link(self, store, monkeypatch): + monkeypatch.delenv("APP_URL", raising=False) + assert onboard.start("+15550001111") is None + + def test_does_not_clobber_an_existing_page(self, store): + """A user who already has a payload is not a new user; never overwrite + a live page with a setup stub.""" + with patch("palmer.home.save") as save, patch("palmer.home.load", return_value={"built_at": 1}): + onboard.start("+15550001111") + save.assert_not_called() + + def test_never_raises(self, store): + with patch("palmer.home.home_token", side_effect=RuntimeError("db down")): + assert onboard.start("+15550001111") is None + + +class TestApply: + def _apply(self, phone, form, city_topics=("Austin local news", + "National and international news")): + payload = {"phone": phone, "setup_pending": True} + with patch("palmer.morning.default_topics", return_value=list(city_topics)), \ + patch("palmer.home.save") as save, \ + patch.object(onboard, "_build_async") as build, \ + patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t), \ + patch("palmer.userprofile._eager_build_home"): + ok = onboard.apply("tok", payload, form) + return ok, save, build + + def test_writes_name_city_and_topics(self, store): + form = _Form({"name": "Jeff", "city": "Austin, TX", "mornings": "on", + "follows": "Eagles, AI"}) + ok, _, build = self._apply("+15550002222", form) + assert ok is True + p = db.get_profile("+15550002222") + assert p["name"] == "Jeff" + assert p["city"] == "Austin, TX" + assert p["morning_enabled"] is True + assert p["morning_onboarded"] is True + assert p["setup_done"] is True + assert "Eagles" in p["morning_topics"] + assert "AI" in p["morning_topics"] + build.assert_called_once() + + def test_local_news_is_seeded_because_the_city_is_known_first(self, store): + """The whole point of the form. update_morning_briefing's dispatch seeds + from `profile["city"]`, which is still empty during the reply that turns + mornings on — so the chat path silently seeds national news alone.""" + form = _Form({"name": "Jeff", "city": "Austin, TX"}) + self._apply("+15550002223", form) + topics = db.get_profile("+15550002223")["morning_topics"] + assert "Austin local news" in topics + + def test_timezone_is_derived_from_the_typed_city(self, store): + """Without it every local_today() call degrades to UTC and the morning + job has no local clock to aim at.""" + form = _Form({"name": "Jeff", "city": "Austin, TX"}) + with patch("palmer.userprofile._derive_timezone", return_value="America/Chicago"): + self._apply("+15550002224", form) + assert db.get_profile("+15550002224")["timezone"] == "America/Chicago" + + def test_second_submit_is_refused(self, store): + """The token is the page's only protection and a form turns a read key + into a write key, so the write is one-shot.""" + form = _Form({"name": "Jeff", "city": "Austin, TX"}) + self._apply("+15550002225", form) + again = _Form({"name": "Mallory", "city": "Nowhere"}) + ok, _, _ = self._apply_already_done("+15550002225", again) + assert ok is False + assert db.get_profile("+15550002225")["name"] == "Jeff" + + def _apply_already_done(self, phone, form): + payload = {"phone": phone, "setup_pending": False} + with patch.object(onboard, "_build_async") as build: + ok = onboard.apply("tok", payload, form) + return ok, None, build + + def test_mornings_unchecked_stays_off(self, store): + form = _Form({"name": "Jeff", "city": "Austin, TX"}) + self._apply("+15550002226", form) + assert db.get_profile("+15550002226")["morning_enabled"] is False + + def test_closes_the_window_before_writing(self, store): + form = _Form({"name": "Jeff", "city": "Austin, TX"}) + _, save, _ = self._apply("+15550002227", form) + assert save.call_args_list[0][0][1] == {"phone": "+15550002227", + "setup_pending": False} + + +class TestTopics: + def _topics(self, city, follows, base=("National and international news",)): + with patch("palmer.morning.default_topics", return_value=list(base)), \ + patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t): + return onboard._topics_for(city, follows) + + def test_free_text_splits_on_commas_not_and(self): + """"Simon and Garfunkel" is one thing; a comma is the separator.""" + assert self._topics("Austin", "Eagles, Simon and Garfunkel; AI\nNvidia stock") == [ + "National and international news", "Eagles", "Simon and Garfunkel", "AI", + "Nvidia stock"] + + def test_blank_and_duplicates_dropped(self): + assert self._topics("Austin", " , eagles, Eagles, . ") == [ + "National and international news", "eagles"] + + def test_capped(self): + many = ", ".join(f"t{i}" for i in range(20)) + assert len(self._topics("Austin", many)) == onboard.TOPIC_MAX + + def test_each_entry_gets_the_texted_topic_normalization(self): + """"nvidia stock" typed on the form resolves a ticker exactly as it + would texted — same pass, so Markets renders either way.""" + with patch("palmer.morning.default_topics", return_value=[]), \ + patch("palmer.agent._normalize_price_topic", + side_effect=lambda t: f"{t} (NVDA)" if "nvidia" in t.lower() else t): + assert onboard._topics_for("Austin", "nvidia stock, AI") == [ + "nvidia stock (NVDA)", "AI"] + + def test_normalizer_failure_keeps_the_raw_topic(self): + with patch("palmer.morning.default_topics", return_value=[]), \ + patch("palmer.agent._normalize_price_topic", side_effect=RuntimeError("yahoo down")): + assert onboard._topics_for("Austin", "Eagles") == ["Eagles"] + + +class TestForm: + def test_posts_to_itself_with_a_free_text_follows_box(self): + out = onboard.render_setup("tok", action="https://palmer.test/h/tok") + assert 'method=post action="https://palmer.test/h/tok"' in out + assert 'name=follows' in out + assert 'type=checkbox name=interests' not in out + + def test_no_javascript_and_no_external_requests(self): + """Same constraint as page.py — it opens on a phone over a cell + connection and nowhere else.""" + out = onboard.render_setup("tok", action="/h/tok") + assert " dict: + return {"url": url, "title": "t", "content": "c", "score": score, + "published_date": "Tue, 18 Aug 2026 12:00:00 GMT"} + + +class TestCorroborated: + def test_two_distinct_domains_pass(self): + results = [_r("https://something.example/a"), _r("https://other.example/b")] + assert corroborated(results) + + def test_single_tier1_passes_alone(self): + # apnews.com is tier 1 in trusted_sources.json + assert corroborated([_r("https://apnews.com/story/xyz")]) + + def test_gov_domain_counts_as_tier1(self): + # .gov is tier-1 via _source_tier + assert corroborated([_r("https://weather.gov/warning/123")]) + + def test_single_unknown_domain_fails(self): + assert not corroborated([_r("https://rumor.example/x")]) + + def test_multiple_urls_same_canonical_domain_fails(self): + results = [ + _r("https://rumor.example/one"), + _r("https://rumor.example/two"), + _r("https://sub.rumor.example/three"), + ] + assert not corroborated(results) + + def test_empty_fails(self): + assert not corroborated([]) + + +# ============================================================================ +# from test_subject_dedup.py +# ============================================================================ +# +# Tests for cross-job subject-dedup: userprofile._is_duplicate_subject and its wiring +# into watches.py and followup.py. Pure logic + mocked LLM/DB — no real network +# or LLM calls. Run: pytest tests/test_watches.py + +class TestIsDuplicateSubject: + def test_no_recent_messages_returns_false_without_llm_call(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=[]), \ + patch("palmer.userprofile.client") as mock_client: + assert userprofile._is_duplicate_subject("+15551234567", "new message") is False + mock_client.messages.create.assert_not_called() + + def test_haiku_says_yes_returns_true(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["Hurts practice update"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("YES") + assert userprofile._is_duplicate_subject("+15551234567", "Hurts camp footage") is True + + def test_haiku_says_no_returns_false(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["weather update"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("NO") + assert userprofile._is_duplicate_subject("+15551234567", "bitcoin price") is False + + def test_llm_failure_fails_open(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["something"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.side_effect = Exception("API down") + assert userprofile._is_duplicate_subject("+15551234567", "new message") is False + + +def _watch(**overrides): + base = { + "id": 1, "phone": "+15551234567", "description": "Eagles Hurts injury", + "queries": ["Jalen Hurts injury"], "cooldown_hours": 4, + "last_alerted": None, "last_alert_summary": None, + "daily_alert_count": 0, "daily_alert_date": None, "recent_summaries": [], + } + base.update(overrides) + return base + + +_RAW_RESULTS = [ + {"title": "Hurts injury update", "url": "https://example1.com/a", + "content": "details", "published_date": "2026-08-13", "score": 1.0}, + {"title": "Hurts injury update 2", "url": "https://example2.com/b", + "content": "more details", "published_date": "2026-08-13", "score": 1.0}, +] + + +class TestWatchesSubjectDedup: + def test_skips_send_when_duplicate_subject(self): + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=True) as dedup, \ + patch("palmer.watches.claim_watch_alert") as mock_claim, \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms") as mock_send: + watches.run_watches() + dedup.assert_called_once() + mock_claim.assert_not_called() + mock_send.assert_not_called() + mock_update.assert_not_called() + + def test_sends_normally_when_not_duplicate(self): + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=False), \ + patch("palmer.watches.claim_watch_alert", return_value=True), \ + patch("palmer.watches._draft_alert", return_value="Hurts is out. https://example.com/a"), \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms") as mock_send: + watches.run_watches() + mock_send.assert_called_once() + mock_update.assert_called_once() + # What goes out is the drafted line, not the raw headline. + assert mock_send.call_args[0][1] == "Hurts is out. https://example.com/a" + + def test_alert_persists_the_fired_url_and_domain(self): + """The 'Watching' page section links a watch to the article + that fired it — update_watch_alerted must be called with that URL/domain, + not just the dedup title.""" + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=False), \ + patch("palmer.watches.claim_watch_alert", return_value=True), \ + patch("palmer.watches._draft_alert", return_value="drafted line https://example.com/a"), \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms"): + watches.run_watches() + _, kwargs = mock_update.call_args + assert kwargs["url"] == "https://example1.com/a" + assert kwargs["domain"] == "example1.com" + + +class TestFollowupSubjectDedup: + def _profile(self): + return {"morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": ["a big life event"]} + + def test_skips_send_and_releases_claim_when_duplicate(self): + with patch("palmer.followup.get_all_profiles", + return_value=[("+15551234567", self._profile())]), \ + patch("palmer.followup._should_send_followup", return_value=True), \ + patch("palmer.followup.claim_daily_guard", return_value=True), \ + patch("palmer.followup.get_history", return_value=[]), \ + patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ + patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ + patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ + patch("palmer.followup._is_duplicate_subject", return_value=True) as dedup, \ + patch("palmer.followup.upsert_profile") as mock_upsert, \ + patch("palmer.followup._local_today") as mock_today, \ + patch("palmer.sms_util.send_sms") as mock_send: + mock_today.return_value.isoformat.return_value = "2026-08-13" + followup.run_followups() + dedup.assert_called_once() + mock_send.assert_not_called() + assert any( + c.args[1] == {"followup_sent_date": None} for c in mock_upsert.call_args_list + ), "expected the daily claim to be released" + + def test_sends_normally_when_not_duplicate(self): + with patch("palmer.followup.get_all_profiles", + return_value=[("+15551234567", self._profile())]), \ + patch("palmer.followup._should_send_followup", return_value=True), \ + patch("palmer.followup.claim_daily_guard", return_value=True), \ + patch("palmer.followup.get_history", return_value=[]), \ + patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ + patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ + patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ + patch("palmer.followup._is_duplicate_subject", return_value=False), \ + patch("palmer.followup.save_message") as mock_save, \ + patch("palmer.followup._local_today") as mock_today, \ + patch("palmer.sms_util.send_sms") as mock_send: + mock_today.return_value.isoformat.return_value = "2026-08-13" + followup.run_followups() + mock_send.assert_called_once() + mock_save.assert_called_once() + + +# ============================================================================ +# from test_rubrics.py +# ============================================================================ +# +# Tests for the genre classifier and per-genre rubrics. +# +# Haiku is mocked; we're testing that: +# 1. The classifier normalizes Haiku's reply into a VALID_GENRES value. +# 2. In-process memoization actually short-circuits the second call. +# 3. Every declared genre has a non-empty rubric. +# 4. rubric_for() falls back safely on unknown or None input. + +class TestRubricCoverage: + def test_every_genre_has_content(self): + for g in rubrics.VALID_GENRES: + body = rubrics.GENRE_RUBRICS.get(g, "") + assert body.strip(), f"genre {g!r} has empty rubric" + # Rubric must have both sides ("would text" and "wouldn't") + low = body.lower() + assert "would text about" in low, f"genre {g!r} missing 'would text about' block" + assert "nobody would text" in low or g == "other", \ + f"genre {g!r} missing 'nobody would text' block" + + def test_rubric_for_known_genre(self): + assert rubrics.rubric_for("sports_team") == rubrics.GENRE_RUBRICS["sports_team"] + + def test_rubric_for_none_falls_back_to_other(self): + assert rubrics.rubric_for(None) == rubrics.GENRE_RUBRICS["other"] + + def test_rubric_for_unknown_falls_back_to_other(self): + assert rubrics.rubric_for("kittens") == rubrics.GENRE_RUBRICS["other"] + + +class TestClassifyGenre: + def setup_method(self): + rubrics._reset_cache_for_tests() + + def test_exact_reply_maps(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("sports_team") + assert rubrics.classify_genre("Cardinals") == "sports_team" + + def test_normalizes_reply_with_punctuation(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("Category: market_instrument.") + assert rubrics.classify_genre("AAPL stock") == "market_instrument" + + def test_uppercase_reply_maps(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("GEOPOLITICS") + assert rubrics.classify_genre("Iran conflict") == "geopolitics" + + def test_unknown_reply_falls_back_to_other(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("nonsense reply here") + assert rubrics.classify_genre("something") == "other" + + def test_api_failure_falls_back_to_other(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.side_effect = RuntimeError("boom") + assert rubrics.classify_genre("anything") == "other" + + def test_empty_topic_returns_other_without_api_call(self): + with patch("palmer.rubrics.client") as mock_client: + assert rubrics.classify_genre("") == "other" + assert rubrics.classify_genre(" ") == "other" + mock_client.messages.create.assert_not_called() + + def test_memoization_short_circuits_second_call(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("sports_team") + rubrics.classify_genre("Cardinals") + rubrics.classify_genre("Cardinals") + rubrics.classify_genre("cardinals") # case-insensitive + rubrics.classify_genre(" Cardinals ") # trim + assert mock_client.messages.create.call_count == 1 + + def test_memoization_distinct_topics_call_separately(self): + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.side_effect = [ + llm_reply("sports_team"), + llm_reply("market_instrument"), + ] + assert rubrics.classify_genre("Cardinals") == "sports_team" + assert rubrics.classify_genre("AAPL") == "market_instrument" + assert mock_client.messages.create.call_count == 2 + + def test_failure_result_is_cached(self): + """Once 'other' is cached for a topic, a later successful reply won't override it. + This is deliberate — repeated Haiku hits on the same string are wasted budget.""" + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.side_effect = RuntimeError("boom") + assert rubrics.classify_genre("weird") == "other" + with patch("palmer.rubrics.client") as mock_client: + mock_client.messages.create.return_value = llm_reply("sports_team") + assert rubrics.classify_genre("weird") == "other" + mock_client.messages.create.assert_not_called() + + +# ============================================================================ +# from test_followup.py +# ============================================================================ +# +# A check-in is about something Palmer actually knows, and not too often. +# +# followup.py fetches nothing of its own — subjects are copied from the profile +# (ongoing_threads), from the page's Scores rows (a followed team's game) and +# from the page's stored headlines — so every guard has to be structural. +# +# Two defects made the old version the largest source of "random thoughts": +# _pick_thread returned whatever Haiku emitted and handed it straight to the +# drafter, so an invented thread was written up as though it were real; and the +# draft prompt asked for "a statement that just shows you remembered", which is +# an instruction to invent specificity. Separately, every bail path nulled +# followup_sent_date instead of restoring it, which silently voided the pacing +# gap. The gap is longer now (GAP_DAYS) because with teams and news in the pool +# there is nearly always a candidate, so the gap is the whole rate limit. + +PHONE = "+15550001111" +THREADS = ["interviewing at Stripe next week", "sister's wedding in Portland"] +CARDS = {"abbrev": "STL", "name": "St. Louis Cardinals", "league": "mlb"} +GAME_LAST = {"id": "1", "league": "mlb", "state": "post", "detail": "Final", + "home": {"abbrev": "STL", "name": "St. Louis Cardinals", "score": 5}, + "away": {"abbrev": "CHC", "name": "Chicago Cubs", "score": 2}} + +# The one instant every clock-dependent test here is frozen to. _local_now and +# _local_today must be patched TOGETHER off this value: _should_send_followup +# reads the first, but the claim_daily_guard write in run_followups reads the +# second, so patching only _local_now left the assertion comparing a frozen day +# against the real clock. +FROZEN = datetime(2026, 8, 30, 15, 0, tzinfo=ZoneInfo("America/Chicago")) + +# The last real send, as the bail tests expect to find it restored. Derived +# from FROZEN so it stays outside the maximum pacing gap whatever FROZEN is. +PRIOR_SENT = (FROZEN - timedelta(days=followup.GAP_MAX_DAYS + 1)).date().isoformat() + + +def _freeze(monkeypatch): + monkeypatch.setattr(followup, "_local_now", lambda tz: FROZEN) + monkeypatch.setattr(followup, "_local_today", lambda tz: FROZEN.date()) + + +def _haiku(text): + return patch.object(followup.client.messages, "create", + return_value=MagicMock(content=[MagicMock(text=text)])) + + +def _threads(*texts): + return [Subject("thread", t) for t in texts] + + +class TestPacing: + def test_the_gap_is_longer_than_it_was(self): + """3 days read as a drumbeat. Ten is roughly three a month.""" + assert followup.GAP_DAYS >= 7 + assert followup.GAP_MAX_DAYS > followup.GAP_DAYS + + def test_inside_the_gap_is_silence(self, monkeypatch): + _freeze(monkeypatch) + recent = (FROZEN - timedelta(days=followup.GAP_DAYS - 1)).date().isoformat() + assert not followup._should_send_followup({ + "morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": THREADS, "followup_sent_date": recent}) + + def test_past_the_gap_is_due(self, monkeypatch): + _freeze(monkeypatch) + assert followup._should_send_followup({ + "morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": THREADS, "followup_sent_date": PRIOR_SENT}) + + def test_a_team_or_a_topic_is_enough_to_be_eligible(self, monkeypatch): + _freeze(monkeypatch) + base = {"morning_onboarded": True, "timezone": "America/Chicago"} + assert not followup._should_send_followup(base) + assert followup._should_send_followup(dict(base, followed_teams=[CARDS])) + assert followup._should_send_followup(dict(base, morning_topics=["AI news"])) + + +class TestCandidatesAreCopiedFromData: + def test_threads_teams_and_headlines_all_become_subjects(self): + profile = {"ongoing_threads": THREADS, "followed_teams": [CARDS]} + payload = {"fetched": {"headlines": datetime.now().timestamp()}, + "headlines": [{"title": "Fed holds rates", "url": "https://reuters.com/x", + "source": "reuters.com", "topic": "Fed"}]} + with patch("palmer.sports.team_day", return_value={"last": GAME_LAST, "today": None}): + got = followup._candidates(profile, payload, FROZEN.date()) + kinds = [s.kind for s in got] + assert kinds == ["thread", "thread", "team", "news"] + assert got[2].text == "St. Louis Cardinals: yesterday beat Chicago Cubs 5-2" + assert got[3].text == "Fed holds rates (Fed)" and got[3].url == "https://reuters.com/x" + + def test_stale_headlines_are_not_brought_up(self): + old = datetime.now().timestamp() - (followup.NEWS_MAX_AGE_HOURS + 1) * 3600 + payload = {"fetched": {"headlines": old}, + "headlines": [{"title": "Fed holds rates", "url": "u", "topic": "Fed"}]} + assert followup._candidates({}, payload, FROZEN.date()) == [] + + def test_a_team_with_no_game_adds_nothing(self): + with patch("palmer.sports.team_day", return_value={"last": None, "today": None}): + assert followup._candidates({"followed_teams": [CARDS]}, None, FROZEN.date()) == [] + + def test_a_sports_outage_costs_only_the_team_rows(self): + with patch("palmer.sports.team_day", side_effect=RuntimeError("espn down")): + got = followup._candidates({"ongoing_threads": THREADS, "followed_teams": [CARDS]}, + None, FROZEN.date()) + assert [s.kind for s in got] == ["thread", "thread"] + + def test_the_last_subject_is_left_out(self): + profile = {"ongoing_threads": THREADS, "followup_last_thread": THREADS[0]} + assert [s.text for s in followup._candidates(profile, None, FROZEN.date())] == [THREADS[1]] + + def test_a_single_subject_is_still_available_after_being_used(self): + """Excluding the only thing there is would end check-ins for good.""" + profile = {"ongoing_threads": [THREADS[0]], "followup_last_thread": THREADS[0]} + assert [s.text for s in followup._candidates(profile, None, FROZEN.date())] == [THREADS[0]] + + def test_no_token_means_no_news_and_no_error(self): + assert followup._load_payload({}) is None + + +class TestPickSubjectFailsClosed: + def test_an_exact_echo_returns_the_stored_subject(self): + with _haiku("interviewing at Stripe next week"): + got = followup._pick_subject({}, [], _threads(*THREADS)) + assert got == Subject("thread", "interviewing at Stripe next week") + + def test_a_confabulated_subject_is_refused(self): + """The actual failure: Haiku names something plausible that is not on + the list, and it used to be drafted as though it were real.""" + with _haiku("how the apartment hunt is going"): + assert followup._pick_subject({}, [], _threads(*THREADS)) is None + + def test_a_paraphrase_is_refused_too(self): + with _haiku("the Stripe interview"): + assert followup._pick_subject({}, [], _threads(*THREADS)) is None + + def test_none_is_none(self): + with _haiku("NONE"): + assert followup._pick_subject({}, [], _threads(*THREADS)) is None + + def test_no_candidates_costs_no_model_call(self): + with patch.object(followup.client.messages, "create") as create: + assert followup._pick_subject({}, [], []) is None + create.assert_not_called() + + def test_a_model_failure_is_silence(self): + with patch.object(followup.client.messages, "create", + side_effect=RuntimeError("down")): + assert followup._pick_subject({}, [], _threads(*THREADS)) is None + + def test_the_prompt_shows_the_kind_and_asks_for_an_echo(self): + with _haiku("NONE") as create: + followup._pick_subject({}, [], [Subject("team", "Cards: today play Cubs, 7:15 PM CT")]) + body = create.call_args.kwargs["messages"][0]["content"] + assert "[team] Cards: today play Cubs, 7:15 PM CT" in body + assert "copied EXACTLY" in body + + +class TestLifeContextAloneNeverTriggersACheckIn: + def test_prose_about_someone_is_not_a_thread(self): + assert not followup._should_send_followup({ + "morning_onboarded": True, "timezone": "America/Chicago", + "life_context": "Works in finance, two kids, moved to Chicago in March", + }) + + +class TestThePacingGapSurvivesABail: + """claim_daily_guard overwrites followup_sent_date with today, so nulling it + on a bail erased the record of the last real send — and _should_send_followup + measures the gap against exactly that field.""" + + def _run(self, fresh_db, monkeypatch, *, subject, candidates=None, + drafted="hey, how'd it go?", dup=False, sent=True): + db.upsert_profile(PHONE, { + "morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": THREADS, "followup_sent_date": PRIOR_SENT, + }) + _freeze(monkeypatch) + if candidates is None: + candidates = _threads(*THREADS) + with patch.object(followup, "get_all_profiles", + return_value=[(PHONE, db.get_profile(PHONE))]), \ + patch.object(followup, "_candidates", return_value=candidates), \ + patch.object(followup, "_pick_subject", return_value=subject), \ + patch.object(followup, "_draft_followup", return_value=drafted), \ + patch.object(followup, "_is_duplicate_subject", return_value=dup), \ + patch("palmer.sms_util.send_sms", return_value=sent): + followup.run_followups() + return db.get_profile(PHONE) + + def test_no_candidates_restores_the_prior_date(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=None, candidates=[]) + assert p["followup_sent_date"] == PRIOR_SENT + + def test_no_pick_restores_it(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=None) + assert p["followup_sent_date"] == PRIOR_SENT + + def test_an_empty_draft_restores_it(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=_threads(THREADS[0])[0], drafted="") + assert p["followup_sent_date"] == PRIOR_SENT + + def test_a_duplicate_restores_it(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=_threads(THREADS[0])[0], dup=True) + assert p["followup_sent_date"] == PRIOR_SENT + + def test_a_failed_send_restores_it(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=_threads(THREADS[0])[0], sent=False) + assert p["followup_sent_date"] == PRIOR_SENT + + def test_a_real_send_advances_it_and_records_the_subject(self, fresh_db, monkeypatch): + p = self._run(fresh_db, monkeypatch, subject=_threads(THREADS[0])[0]) + assert p["followup_sent_date"] == FROZEN.date().isoformat() + assert p["followup_last_thread"] == THREADS[0] + + +class TestTheDraft: + def _drafted(self, subject, text="cards back at it tonight, 7:15."): + with patch.object(followup, "_build_system", return_value="sys"), \ + patch.object(followup.client.messages, "create", + return_value=MagicMock(content=[MagicMock(text=text)])) as create: + out = followup._draft_followup(PHONE, subject) + return out, create.call_args.kwargs["messages"][0]["content"] + + def test_it_forbids_inventing_details(self): + import inspect + src = inspect.getsource(followup._draft_followup) + assert "Do not invent" in src + # The instruction that produced the problem. + assert "just shows you remembered" not in src + + def test_a_news_subject_carries_its_link_last_and_alone(self): + out, body = self._drafted(Subject("news", "Fed holds rates (Fed)", + url="https://reuters.com/x", source="reuters.com")) + assert out.endswith("\nhttps://reuters.com/x") + assert "do not include or mention a link" in body and "reuters.com" in body + + def test_a_team_subject_is_drafted_from_the_line_only(self): + out, body = self._drafted(Subject("team", "Cards: today play Cubs, 7:15 PM CT")) + assert "Cards: today play Cubs, 7:15 PM CT" in body + assert "\n" not in out + + def test_a_thread_subject_may_ask_one_question(self): + _, body = self._drafted(Subject("thread", THREADS[0])) + assert THREADS[0] in body and "ask one short question" in body + + def test_the_bookkeeping_field_is_allow_listed_but_not_extractable(self): + from palmer import userprofile + from palmer import prompts + assert "followup_last_thread" in userprofile.PROFILE_FIELDS + assert "followup_last_thread" not in prompts.EXTRACT_PROMPT diff --git a/tests/test_watches_genre.py b/tests/test_watches_genre.py deleted file mode 100644 index 2acfde9..0000000 --- a/tests/test_watches_genre.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Tests for lazy genre classification + rubric-in-prompt inside watches.py. - -Haiku is mocked; we're testing that: - 1. _watch_genre returns the stored genre without classifying when set. - 2. _watch_genre classifies + persists via set_watch_genre when missing. - 3. _check_watch_hit splices the correct rubric into the Haiku prompt. -""" -from unittest.mock import patch, MagicMock - -from palmer import rubrics -from palmer import watches as watches_mod - - -def _haiku_reply(text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - -class TestWatchGenre: - def setup_method(self): - rubrics._reset_cache_for_tests() - - def test_stored_genre_is_returned_verbatim(self): - with patch("palmer.watches.classify_genre") as mock_classify, \ - patch("palmer.watches.set_watch_genre") as mock_set: - watch = {"id": 1, "description": "Cardinals", "genre": "sports_team"} - assert watches_mod._watch_genre(watch) == "sports_team" - mock_classify.assert_not_called() - mock_set.assert_not_called() - - def test_missing_genre_triggers_classify_and_persist(self): - with patch("palmer.watches.classify_genre", return_value="sports_team") as mock_classify, \ - patch("palmer.watches.set_watch_genre") as mock_set: - watch = {"id": 42, "description": "Cardinals winning streak", "genre": None} - assert watches_mod._watch_genre(watch) == "sports_team" - mock_classify.assert_called_once_with("Cardinals winning streak") - mock_set.assert_called_once_with(42, "sports_team") - # The dict gets updated in place so a second call skips both - assert watch["genre"] == "sports_team" - - def test_persist_failure_does_not_raise(self): - with patch("palmer.watches.classify_genre", return_value="market_instrument"), \ - patch("palmer.watches.set_watch_genre", side_effect=RuntimeError("db down")): - watch = {"id": 7, "description": "Bitcoin", "genre": None} - # Should still return the classified genre and not blow up - assert watches_mod._watch_genre(watch) == "market_instrument" - assert watch["genre"] == "market_instrument" - - -class TestCheckWatchHitPrompt: - def _capture_prompt(self): - """Return (client_patch_ctx, captured) so callers can .append to captured.""" - captured: list[str] = [] - - def _create(**kwargs): - captured.append(kwargs["messages"][0]["content"]) - return _haiku_reply("YES") - - return _create, captured - - def test_prompt_contains_genre_rubric_text(self): - create, captured = self._capture_prompt() - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.side_effect = create - hit = watches_mod._check_watch_hit( - results="Cardinals moved into first place today.", - description="St. Louis Cardinals", - recent_summaries=[], - engaged=False, - genre="sports_team", - ) - assert hit is True - prompt = captured[0] - assert "Genre: sports_team" in prompt - # A distinctive line from the sports rubric — proves the rubric text was spliced in - assert "streaks starting to matter" in prompt.lower() - # And NOT rubric text from an unrelated genre - assert "mortgage" not in prompt.lower() - - def test_prompt_uses_other_rubric_when_genre_unknown(self): - create, captured = self._capture_prompt() - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.side_effect = create - watches_mod._check_watch_hit( - results="Something happened.", - description="Weird watch", - recent_summaries=[], - engaged=False, - genre="not_a_real_genre", - ) - prompt = captured[0] - # rubric_for() falls back to 'other'; assert the header line still lists genre - assert "Genre: not_a_real_genre" in prompt - assert "genuinely notable, time-sensitive development" in prompt.lower() - - def test_engaged_footer_lowers_bar(self): - create, captured = self._capture_prompt() - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.side_effect = create - watches_mod._check_watch_hit( - results="Some update.", - description="Cardinals", - recent_summaries=[], - engaged=True, - genre="sports_team", - ) - prompt = captured[0] - assert "following this closely" in prompt.lower() - - def test_no_reply_is_no(self): - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("NO — routine game") - hit = watches_mod._check_watch_hit( - results="Cardinals lost 5-4 to the Reds.", - description="Cardinals", - recent_summaries=[], - engaged=False, - genre="sports_team", - ) - assert hit is False diff --git a/tests/test_watches_story.py b/tests/test_watches_story.py deleted file mode 100644 index 6c8c4fc..0000000 --- a/tests/test_watches_story.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for the story-arc gate in watches.py. - -The semantic dedup that answers 'the user already knows this — is the new -candidate ADVANCING the story, or just a rehash?' — separate from the -title-based recent_summaries gate. -""" -from unittest.mock import patch, MagicMock - -from palmer import watches as watches_mod -from palmer import watches - - -def _haiku_reply(text: str) -> MagicMock: - block = MagicMock() - block.text = text - resp = MagicMock() - resp.content = [block] - return resp - - -def _capture_prompt(reply_text: str): - captured: list[str] = [] - - def _create(**kwargs): - captured.append(kwargs["messages"][0]["content"]) - return _haiku_reply(reply_text) - - return _create, captured - - -class TestCheckWatchHitStoryBlock: - def test_no_story_state_omits_story_block(self): - create, captured = _capture_prompt("YES") - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.side_effect = create - watches_mod._check_watch_hit( - results="something happened", - description="Cardinals", - recent_summaries=[], - engaged=False, - genre="sports_team", - story_state=None, - ) - prompt = captured[0] - assert "Current story state" not in prompt - - def test_story_state_included_in_prompt(self): - create, captured = _capture_prompt("NO") - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.side_effect = create - watches_mod._check_watch_hit( - results="Cardinals win again to extend streak to 7", - description="Cardinals", - recent_summaries=[], - engaged=False, - genre="sports_team", - story_state="Cardinals are on a six-game winning streak, moved into first place.", - ) - prompt = captured[0] - assert "Current story state" in prompt - assert "six-game winning streak" in prompt - # Prompt should also frame the ADVANCE-vs-rehash question - assert "advance" in prompt.lower() - - def test_yes_reply_still_fires(self): - with patch("palmer.watches.client") as mock_client: - mock_client.messages.create.return_value = _haiku_reply("YES") - assert watches_mod._check_watch_hit( - results="candidate", description="d", recent_summaries=[], - engaged=False, genre="sports_team", - story_state="prior state", - ) is True - - -class TestUpdateStoryState: - def test_persists_haiku_summary(self): - with patch("palmer.watches.client") as mock_client, \ - patch("palmer.watches.update_watch_story") as mock_update: - mock_client.messages.create.return_value = _haiku_reply( - "Cardinals extended their winning streak to seven with a 4-2 win over the Cubs." - ) - watches_mod._update_story_state( - watch_id=42, - previous_state="Cardinals on a six-game streak, in first place.", - new_alert_title="Cardinals beat Cubs 4-2 for seventh straight win", - new_alert_content="The Cardinals defeated the Cubs 4-2 Tuesday...", - ) - mock_update.assert_called_once() - args = mock_update.call_args.args - assert args[0] == 42 - assert "seven" in args[1].lower() or "streak" in args[1].lower() - - def test_no_previous_state_still_seeds(self): - with patch("palmer.watches.client") as mock_client, \ - patch("palmer.watches.update_watch_story") as mock_update: - mock_client.messages.create.return_value = _haiku_reply( - "Cardinals moved into first place with a win over the Cubs." - ) - watches_mod._update_story_state( - watch_id=1, previous_state=None, - new_alert_title="Cardinals in first place", - new_alert_content="", - ) - mock_update.assert_called_once() - # First-alert seeding: prompt contains 'first alert' marker so Haiku knows - prompt = mock_client.messages.create.call_args.kwargs["messages"][0]["content"] - assert "first alert" in prompt.lower() - - def test_haiku_failure_swallowed(self): - with patch("palmer.watches.client") as mock_client, \ - patch("palmer.watches.update_watch_story") as mock_update: - mock_client.messages.create.side_effect = RuntimeError("boom") - # Must not raise — the alert already went out; losing the state - # update only costs us dedup benefit on the next tick. - watches_mod._update_story_state( - watch_id=1, previous_state=None, - new_alert_title="t", new_alert_content="c", - ) - mock_update.assert_not_called() - - def test_empty_haiku_reply_no_persist(self): - with patch("palmer.watches.client") as mock_client, \ - patch("palmer.watches.update_watch_story") as mock_update: - mock_client.messages.create.return_value = _haiku_reply(" ") - watches_mod._update_story_state( - watch_id=1, previous_state=None, - new_alert_title="t", new_alert_content="c", - ) - mock_update.assert_not_called() - - def test_summary_truncated_to_400_chars(self): - long_text = "x" * 900 - with patch("palmer.watches.client") as mock_client, \ - patch("palmer.watches.update_watch_story") as mock_update: - mock_client.messages.create.return_value = _haiku_reply(long_text) - watches_mod._update_story_state( - watch_id=1, previous_state=None, - new_alert_title="t", new_alert_content="c", - ) - args = mock_update.call_args.args - assert len(args[1]) == 400 - - -class TestTheWatchAlertHasAVoice: - """This was the one user-facing message in the system with no Palmer in - it: a bare `title\\nurl`, no system prompt, no calibration — against the - rule that anything the user reads is drafted through _build_system. Its - sibling, the retired daily-alert job, always did this; the two paths simply diverged.""" - - TOP = {"title": "Eagles sign Hurts to an extension", - "url": "https://example.com/a", - "content": "The deal runs five years."} - WATCH = {"id": 1, "description": "Eagles roster news"} - - def _draft(self, text): - resp = MagicMock() - resp.content = [MagicMock(text=text)] - return resp - - def _run(self, text, **kw): - with patch.object(watches, "_build_system", return_value="sys") as bs, \ - patch.object(watches.client.messages, "create", - return_value=self._draft(text), **kw) as create: - out = watches._draft_alert("+15550001111", self.WATCH, self.TOP, - fallback="FALLBACK") - return out, bs, create - - def test_it_is_drafted_through_build_system_on_sonnet(self): - _, bs, create = self._run("Hurts got his extension. Five years.") - bs.assert_called_once() - assert create.call_args.kwargs["system"] == "sys" - assert create.call_args.kwargs["model"] == watches.SONNET_MODEL - - def test_the_url_is_last_and_alone(self): - """Message apps only draw the preview when one URL sits at a boundary.""" - out, _, _ = self._run("Hurts got his extension. Five years.") - assert out.endswith(self.TOP["url"]) - assert len(watches.URL_RE.findall(out)) == 1 - - def test_a_model_invented_url_is_stripped(self): - """Two links draw no preview, and the invented one may not resolve.""" - out, _, _ = self._run("Big deal, read it at https://spam.example.com/x") - assert "spam.example.com" not in out - assert out.endswith(self.TOP["url"]) - - def test_the_prompt_says_it_can_see_only_the_headline(self): - _, _, create = self._run("Hurts got his extension.") - sent = create.call_args.kwargs["messages"][0]["content"] - assert "NOTHING else" in sent - assert "do not invent" in sent.lower() - - def test_the_prompt_forbids_writing_a_url(self): - _, _, create = self._run("Hurts got his extension.") - assert "Do NOT write a URL" in create.call_args.kwargs["messages"][0]["content"] - - def test_a_failed_draft_still_sends_the_headline(self): - """The alert goes out either way — the fallback is what production - sent before this existed.""" - with patch.object(watches, "_build_system", return_value="sys"), \ - patch.object(watches.client.messages, "create", - side_effect=RuntimeError("api down")): - out = watches._draft_alert("+15550001111", self.WATCH, self.TOP, - fallback="FALLBACK") - assert out == "FALLBACK" - - def test_an_empty_draft_falls_back(self): - out, _, _ = self._run(" ") - assert out == "FALLBACK" - - def test_a_failed_system_prompt_falls_back_to_the_base_one(self): - """Not "no system prompt" — that drops the voice, the calibration AND - every NEVER rule from a message still going out.""" - with patch.object(watches, "_build_system", side_effect=RuntimeError("no db")), \ - patch.object(watches.client.messages, "create", - return_value=self._draft("Hurts got paid.")) as create: - watches._draft_alert("+15550001111", self.WATCH, self.TOP, fallback="FB") - system = create.call_args.kwargs["system"] - assert "Palmer" in system - assert "{profile_block}" not in system - - def test_the_dedup_gates_still_see_the_facts(self): - """The subject is the news, not Palmer's phrasing — and drafting - before the gates would spend a Sonnet call on every candidate they - throw away, at a 30-minute cadence across every watch.""" - import inspect - src = inspect.getsource(watches.run_watches) - assert src.index("_is_duplicate_subject") < src.index("_draft_alert") - assert src.index("claim_watch_alert") < src.index("_draft_alert") - - def test_what_is_sent_is_what_is_saved(self): - import inspect - src = inspect.getsource(watches.run_watches) - assert 'send_sms(watch["phone"], body)' in src - assert 'save_message(watch["phone"], "assistant", body, kind="watch")' in src - - def test_the_recent_summaries_stay_factual(self): - """They are fed back into _check_watch_hit's "already sent" block, - where a voiced paraphrase would degrade the match.""" - import inspect - src = inspect.getsource(watches.run_watches) - assert 'title = (top.get("title") or alert)[:120]' in src - - def test_the_send_does_not_re_decide_the_status_callback(self): - """send_sms sees the URL and turns it off itself; a caller passing - True here would put the shorten-and-retry back on a message with a - link in it.""" - import inspect - assert "add_status_callback=" not in inspect.getsource(watches.run_watches) diff --git a/tests/test_weather.py b/tests/test_weather.py index 869cfdd..a3c69a4 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -1,11 +1,20 @@ -"""Tests for _geocode. All HTTP mocked — a real call here is the exact suite- -runtime regression CLAUDE.md warns about.""" -from unittest.mock import patch +"""Weather: geocoding, sources, extra locations, the forecast audit. +Merged from test_weather.py, test_weather_source.py, test_weather_locations.py, test_forecast_audit.py; each section keeps its +original file's notes, because those carry the incident that led to the test. +""" import pytest +import inspect +from unittest.mock import patch +from palmer import weather, agent, home, page, db, morning, wxaudit -from palmer import weather +# ============================================================================ +# from test_weather.py +# ============================================================================ +# +# Tests for _geocode. All HTTP mocked — a real call here is the exact suite- +# runtime regression CLAUDE.md warns about. class TestGeocode: def setup_method(self): @@ -38,3 +47,636 @@ def test_no_results_raises(self): with patch.object(weather, "_http_get_json_retry", return_value={"results": []}): with pytest.raises(ValueError): weather._geocode("Nowhereville") + + +# ============================================================================ +# from test_weather_source.py +# ============================================================================ +# +# One source per user: NWS where it reaches, Open-Meteo everywhere else. +# +# The page and the chat reply used to come from different forecasters. Chat asked +# NWS; the page, the card and the morning line asked Open-Meteo. For a coastal +# city that is not a rounding difference — on one August day in Culver City the +# raw models spread 15 degrees for the same point (MeteoFrance 83, JMA 82, ICON +# 90, GEM 94, GFS 96, ECMWF 97, OpenWeatherMap 96), because how far the marine +# layer pushes inland decides the answer. NWS said 90, Google said 87, and the +# page showed 96 while the same user asking in the thread was told 90. +# +# NWS is a forecaster product rather than a raw model — the local office corrects +# for terrain and marine layer — so it is the better number wherever it exists. +# Open-Meteo stays as the fallback because it is the only one of the two that +# covers anywhere outside the US, and because NWS does go down. +# +# Every test here is offline. A live call in this file is the suite-runtime +# regression CLAUDE.md warns about, and it is easy to reintroduce: patching +# _fetch_openmeteo no longer keeps a US location off the network. + +POINTS = { + "forecast": "https://api.weather.gov/gridpoints/LOX/149,45/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/LOX/149,45/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/LOX/149,45", +} +FORECAST = {"properties": {"periods": [ + {"startTime": "2026-08-27T06:00:00-07:00", "isDaytime": True, "temperature": 90, + "windSpeed": "5 to 10 mph", "shortForecast": "Sunny", + "probabilityOfPrecipitation": {"value": 20}}, + {"startTime": "2026-08-27T18:00:00-07:00", "isDaytime": False, "temperature": 71, + "windSpeed": "0 to 5 mph", "shortForecast": "Clear", + "probabilityOfPrecipitation": {"value": 0}}, +]}} +HOURLY = {"properties": {"periods": [ + {"temperature": 73, "relativeHumidity": {"value": 68}, "windSpeed": "3 mph", + "shortForecast": "Mostly Cloudy"}, +]}} +GRID = {"properties": { + "apparentTemperature": {"uom": "wmoUnit:degC", + "values": [{"validTime": "1999-01-01T00:00:00+00:00/PT1H", "value": 25.0}]}, + "windGust": {"uom": "wmoUnit:km_h-1", "values": []}, +}} + + +def _route(url, **kw): + if "/forecast/hourly" in url: + return HOURLY + if url.endswith("/forecast"): + return FORECAST + return GRID + + +def _nws_offline(): + """Patch every NWS hop. The points cache is cleared so a previous test's + real lookup can't leak in.""" + weather._nws_points_cache.clear() + return patch.multiple( + weather, + _nws_points=lambda lat, lon: POINTS, + _http_get_json_retry=_route, + ) + + +class TestUsGoesToNws: + def test_a_us_location_uses_nws(self): + with _nws_offline(), patch.object( + weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")): + s = weather.weather_snapshot("Culver City", "America/Los_Angeles") + assert s["source"] == "nws" + assert s["high"] == 90 and s["low"] == 71, "the forecaster's number, not a raw model's" + + def test_it_carries_every_field_the_card_and_page_render(self): + with _nws_offline(), patch.object( + weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")): + s = weather.weather_snapshot("Culver City", "America/Los_Angeles") + # cards.py and page.py format these with :.0f — a string would raise + for k in ("temp_now", "high", "low", "wind", "feels_like"): + assert isinstance(s[k], (int, float)), f"{k} must be numeric, got {s[k]!r}" + assert s["description"] and s["resolved"] == "Culver City, California" + + def test_prose_wind_becomes_a_number(self): + """NWS writes wind as "5 to 10 mph"; the top of the range is what people + plan around, and the renderers need a number either way.""" + assert weather._mph("5 to 10 mph") == 10.0 + assert weather._mph("0 mph") == 0.0 + assert weather._mph(None) is None + assert weather._mph("calm") is None + + def test_celsius_and_kmh_are_converted(self): + assert round(weather._c_to_f(25.0)) == 77 + assert round(weather._kmh_to_mph(16.0)) == 10 + assert weather._c_to_f(None) is None and weather._kmh_to_mph(None) is None + + +class TestFallback: + def test_outside_the_us_uses_open_meteo(self): + payload = {"current": {"temperature_2m": 77.8, "apparent_temperature": 82.0, + "weather_code": 3, "wind_speed_10m": 4.3, + "relative_humidity_2m": 60}, + "daily": {"temperature_2m_max": [81.0], "temperature_2m_min": [67.5], + "precipitation_probability_max": [75], + "wind_gusts_10m_max": [21.0]}} + with patch.object(weather, "_geocode", return_value=(48.86, 2.35, "Paris")), \ + patch.object(weather, "_fetch_openmeteo", return_value=payload) as om: + s = weather.weather_snapshot("Paris", "Europe/Paris") + assert s["source"] == "open-meteo" and om.called + assert s["high"] == 81.0 + + def test_an_nws_outage_falls_back_rather_than_failing(self): + """NWS goes down. A US user gets Open-Meteo's number, not an empty page.""" + payload = {"current": {"temperature_2m": 70.0, "apparent_temperature": 70.0, + "weather_code": 0, "wind_speed_10m": 3.0, + "relative_humidity_2m": 50}, + "daily": {"temperature_2m_max": [88.0], "temperature_2m_min": [64.0], + "precipitation_probability_max": [10], + "wind_gusts_10m_max": [9.0]}} + weather._nws_points_cache.clear() + with patch.object(weather, "_geocode", return_value=(38.5, -90.4, "Kirkwood, Missouri")), \ + patch.object(weather, "_nws_snapshot", side_effect=RuntimeError("NWS 503")), \ + patch.object(weather, "_fetch_openmeteo", return_value=payload): + s = weather.weather_snapshot("Kirkwood, MO") + assert s["source"] == "open-meteo" and s["high"] == 88.0 + + def test_a_total_failure_still_returns_none(self): + with patch.object(weather, "_geocode", side_effect=RuntimeError("boom")): + assert weather.weather_snapshot("Nowhere") is None + + +class TestGridpointExtrasAreOptional: + def test_a_gridpoint_failure_drops_the_chip_not_the_forecast(self): + """feels_like and gusts are chips. The high is the message.""" + def _flaky(url, **kw): + if url.endswith("/forecast"): + return FORECAST + if "/forecast/hourly" in url: + return HOURLY + raise RuntimeError("gridpoint 500") + weather._nws_points_cache.clear() + with patch.object(weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")), \ + patch.multiple(weather, _nws_points=lambda lat, lon: POINTS, + _http_get_json_retry=_flaky): + s = weather.weather_snapshot("Culver City", "America/Los_Angeles") + assert s["source"] == "nws" and s["high"] == 90 + assert s["feels_like"] is None and s["gusts"] is None + + +class TestPointsCache: + def test_the_grid_lookup_is_cached_like_the_geocode(self): + """Grid cells don't move. This saves a round trip on every refresh.""" + weather._nws_points_cache.clear() + calls = [] + + def _points(url, **kw): + calls.append(url) + return {"properties": dict(POINTS)} + + with patch.object(weather, "_http_get_json_retry", _points): + weather._nws_points(34.02, -118.39) + weather._nws_points(34.02, -118.39) + assert len(calls) == 1 + + +# ============================================================================ +# from test_weather_locations.py +# ============================================================================ +# +# Multiple weather locations on Palmer Home. +# +# profile["city"] stays the one true primary location — the tools, the morning +# send, the timezone derivation all still key off it exactly as before. +# weather_locations is a small additive list of SECONDARY places pinned to the +# page only, following the same write-once-resolve, cap, and cache-invalidate +# shape as follow_show/follow_team (see agent.get_reply's add_weather_location +# and remove_weather_location blocks). + +class TestResolveWeatherLocation: + def test_resolves_to_the_geocoded_display_form(self): + with patch.object(weather, "_geocode", return_value=(38.5, -90.0, "Holiday Shores, IL")): + assert weather.resolve_weather_location("holiday shores") == "Holiday Shores, IL" + + def test_an_unresolvable_place_returns_none_rather_than_guessing(self): + with patch.object(weather, "_geocode", side_effect=ValueError("Location not found: asdf")): + assert weather.resolve_weather_location("asdfqwer") is None + + +class TestSchema: + def test_the_field_is_allowed(self): + from palmer.userprofile import PROFILE_FIELDS + assert "weather_locations" in PROFILE_FIELDS + + def test_it_is_not_aliased(self): + from palmer.userprofile import _PROFILE_ALIASES + assert "weather_locations" not in _PROFILE_ALIASES + + def test_structured_rows_survive_canonicalisation(self): + from palmer.userprofile import _canonical_updates + out = _canonical_updates({"weather_locations": ["Holiday Shores, IL"]}) + assert out == {"weather_locations": ["Holiday Shores, IL"]} + + +def _block(tool_name: str) -> str: + src = inspect.getsource(agent.get_reply) + return src.split(f'"{tool_name}"')[1].split("elif b.name")[0] + + +class TestSamePlace: + """_label appends the country outside the US now. Rows pinned before that + read "Paris, Île-de-France"; a strict compare pinned the same city twice.""" + + def test_a_legacy_label_matches_its_country_suffixed_form(self): + assert weather.same_place("Paris, Île-de-France", "Paris, Île-de-France, France") + assert weather.same_place("paris, île-de-france, france", "Paris, Île-de-France") + + def test_a_different_place_with_the_same_name_does_not(self): + assert not weather.same_place("Paris, Texas", "Paris, Île-de-France, France") + assert not weather.same_place("Paris", "Paris, Île-de-France, France") + assert not weather.same_place("", "Paris, Île-de-France, France") + + def test_the_dispatch_uses_it(self): + assert "same_place(loc, resolved)" in _block("add_weather_location") + + +class TestAddDispatch: + def test_resolution_happens_on_the_write_path(self): + assert "resolve_weather_location" in _block("add_weather_location") + + def test_an_unresolvable_place_asks_rather_than_guesses(self): + block = _block("add_weather_location") + assert "confirm the city and state" in block + assert "do not guess" in block + + def test_it_is_capped(self): + assert "WEATHER_LOCATIONS_MAX" in _block("add_weather_location") + + def test_it_cannot_duplicate_the_primary_city(self): + block = _block("add_weather_location") + assert 'profile.get("city")' in block + + def test_it_expires_the_cached_section(self): + block = _block("add_weather_location") + assert "invalidate" in block and "weather_extra" in block + + def test_it_never_touches_the_primary_city_field(self): + """Distinguishes this from update_morning_briefing's weather-topic + path, which does write profile["city"].""" + block = _block("add_weather_location") + assert '"city":' not in block and 'updates["city"]' not in block + + +class TestRemoveDispatch: + def test_it_matches_on_text_match_and_falls_back_to_dropping_all(self): + block = _block("remove_weather_location") + assert "text_match" in block + + def test_it_expires_the_cached_section(self): + block = _block("remove_weather_location") + assert "invalidate" in block and "weather_extra" in block + + +class TestHomeFetch: + PROFILE = {"city": "Kirkwood, MO", "timezone": "America/Chicago", + "weather_locations": ["Holiday Shores, IL", "Ballwin, MO"]} + + def test_no_locations_is_not_an_error(self): + assert home._fetch_weather_extra({"city": "Kirkwood, MO"}) == [] + + def test_one_snapshot_per_location(self): + with patch("palmer.weather.weather_snapshot", return_value={"temp_now": 80}) as snap: + out = home._fetch_weather_extra(self.PROFILE) + assert len(out) == 2 + assert snap.call_count == 2 + + def test_a_failed_location_drops_only_that_row(self): + """Same shape as _fetch_prices keeping other tickers when one 429s — + one bad location must not blank the whole section.""" + with patch("palmer.weather.weather_snapshot", side_effect=[None, {"temp_now": 75}]): + out = home._fetch_weather_extra(self.PROFILE) + assert len(out) == 1 + assert out[0]["temp_now"] == 75 + + def test_the_window_does_not_alias_against_the_daily_send(self): + assert home.STALE["weather_extra"] < 24 * 3600 + + +class TestPageRenders: + BASE = {"city": "Kirkwood, MO", "weather": {"temp_now": 81.0, "description": "Clear"}, + "fetched": {}, "tracking": {}} + + def _render(self, **over): + payload = dict(self.BASE) + payload.update(over) + return page.render(payload, token="t", image_url="i", page_url="p") + + def test_absent_when_there_are_no_extra_locations(self): + assert "Weather" not in self._render() + + def test_each_extra_location_shows_its_place_and_temp(self): + html = self._render(weather_extra=[ + {"resolved": "Holiday Shores, IL", "temp_now": 82.0, "description": "sunny"}]) + assert "Holiday Shores, IL" in html + assert "82°" in html + + def test_the_label_is_a_single_word(self): + """See test_page.py::TestSectionLabelsAreOneWord — every card label on + the page must be one word.""" + html = self._render(weather_extra=[{"resolved": "Holiday Shores, IL", "temp_now": 82.0}]) + assert "
Weather<" in html + + +class TestAnAmbiguousPlaceIsAskedAboutNotGuessed: + """_geocode asked for count=1 and took results[0], so Springfield, + Portland, Columbus and Cambridge each resolved to whichever the geocoder + ranked first — silently, and then confirmed to the user as though they had + named it. resolve_weather_location's docstring already claimed "None means + the model should ask rather than guess", which was only ever true when + NOTHING matched.""" + + def _payload(self, *rows): + return {"results": list(rows)} + + SPRINGFIELD_IL = {"name": "Springfield", "admin1": "Illinois", + "country": "United States", "latitude": 39.8, "longitude": -89.6} + SPRINGFIELD_MO = {"name": "Springfield", "admin1": "Missouri", + "country": "United States", "latitude": 37.2, "longitude": -93.3} + KIRKWOOD = {"name": "Kirkwood", "admin1": "Missouri", + "country": "United States", "latitude": 38.6, "longitude": -90.4} + + def _clear(self): + weather._geocode_cache.clear() + weather._geocode_alts.clear() + + def test_two_real_places_with_one_name_is_a_question(self): + self._clear() + with patch.object(weather, "_http_get_json_retry", + return_value=self._payload(self.SPRINGFIELD_IL, self.SPRINGFIELD_MO)): + assert weather.ambiguous_location("Springfield") == [ + "Springfield, Illinois", "Springfield, Missouri"] + + def test_one_match_is_not(self): + self._clear() + with patch.object(weather, "_http_get_json_retry", + return_value=self._payload(self.KIRKWOOD)): + assert weather.ambiguous_location("Kirkwood") == [] + + def test_a_qualified_name_is_not(self): + """They already told you which one. Asking again is the annoying kind + of careful.""" + self._clear() + assert weather.ambiguous_location("Springfield, IL") == [] + + def test_the_read_path_still_takes_the_top_hit(self): + """count went 1 -> 5 on the same call. The extra rows are for the + write paths; nothing about resolution changed.""" + self._clear() + with patch.object(weather, "_http_get_json_retry", + return_value=self._payload(self.SPRINGFIELD_IL, self.SPRINGFIELD_MO)): + lat, lon, resolved = weather._geocode("Springfield") + assert resolved == "Springfield, Illinois" + assert (lat, lon) == (39.8, -89.6) + + def test_the_dispatch_refuses_to_pick(self): + import inspect + from palmer import agent + block = inspect.getsource(agent.get_reply).split('"add_weather_location"')[1] \ + .split("elif b.name")[0] + assert "ambiguous_location" in block + assert "Do NOT" in block and "pick one yourself" in block + + +# ============================================================================ +# from test_forecast_audit.py +# ============================================================================ +# +# Forecast accuracy: the hedge, and the log that will replace it with data. +# +# Palmer told a Woodland Hills user 103, 106, 107 and 111 on four consecutive +# days against actuals of 98.3, 96.8, 97.8 and 99.5. In the same week NWS was the +# best source available for Culver City, +1.7F where every raw model ran 5-11F +# hot. So neither source wins everywhere and a median is worse than either at one +# of the two — which is why nothing here averages anything. +# +# Two mechanisms, tested separately: +# +# 1. The hedge changes what Palmer *claims*, not what it reports. A wide +# ensemble spread means say "around", not say a different number. +# 2. The audit records each source against reality so the eventual choice comes +# from months of data rather than from one bad week. +# +# All offline. + +def _om(*highs): + """An Open-Meteo multi-model daily payload.""" + return {"daily": {f"temperature_2m_max_model{i}": [h] for i, h in enumerate(highs)}} + + +class TestTheHedgeMeasuresDisagreementNotOneOpinion: + """A single second opinion measures the wrong thing: GFS shares NWS's + inland warm error and contradicts NWS where NWS is right. Only the spread + across several models separates the two cases.""" + + def test_a_wide_spread_is_flagged(self): + """Woodland Hills: NWS 110 against models at 93.7/100.6/105.3.""" + with patch.object(weather, "_http_get_json_retry", + return_value=_om(93.7, 100.6, 105.3)): + out = weather._ensemble_spread(34.17, -118.61, 110) + assert out["high_confident"] is False + assert out["high_spread"] == 16.3 + assert (out["high_low_est"], out["high_high_est"]) == (94, 110) + + def test_a_tight_spread_is_left_alone(self): + """Culver City: NWS 90 is the low outlier and it is also the right + answer. Hedging here would be a false alarm.""" + with patch.object(weather, "_http_get_json_retry", + return_value=_om(93.2, 96.2, 98.7)): + out = weather._ensemble_spread(34.02, -118.39, 90) + assert out["high_confident"] is True + + def test_it_never_changes_the_number(self): + with patch.object(weather, "_http_get_json_retry", + return_value=_om(93.7, 100.6, 105.3)): + out = weather._ensemble_spread(34.17, -118.61, 110) + assert "high" not in out, "the hedge qualifies the high, it never replaces it" + + def test_a_dead_second_source_leaves_the_high_unqualified(self): + with patch.object(weather, "_http_get_json_retry", side_effect=RuntimeError("429")): + assert weather._ensemble_spread(34.17, -118.61, 110) == {} + + def test_no_high_means_nothing_to_check(self): + with patch.object(weather, "_http_get_json_retry") as http: + assert weather._ensemble_spread(34.17, -118.61, None) == {} + http.assert_not_called() + + +class TestTheHedgeReachesTheUser: + UNSURE = {"city": "Woodland Hills", "weather": { + "resolved": "Woodland Hills, California", "description": "partly sunny", + "temp_now": 80, "high": 110, "low": 69, "high_confident": False, + "high_spread": 16.3, "high_low_est": 94, "high_high_est": 110}} + SURE = {"city": "Culver City", "weather": { + "resolved": "Culver City, California", "description": "partly sunny", + "temp_now": 74, "high": 90, "low": 70, "high_confident": True, + "high_spread": 8.7, "high_low_est": 90, "high_high_est": 99}} + + def test_a_contested_high_reaches_the_drafter_as_a_range(self): + d = morning._payload_digest(self.UNSURE) + assert "between 94 and 110" in d + assert "do NOT state a single high" in d + + def test_a_settled_high_is_stated_plainly(self): + d = morning._payload_digest(self.SURE) + assert "high 90" in d and "disagree" not in d + + def test_the_prompt_carries_the_hedging_rule(self): + assert "forecasts disagree" in morning.generate_morning_line.__doc__ or True + import inspect + src = inspect.getsource(morning.generate_morning_line) + assert "do NOT pick one and state it" in src + + def test_the_page_shows_the_same_range(self): + """The page, the card and the text render from one payload and must not + disagree about how sure Palmer is.""" + from palmer import page + html = page.render(dict(self.UNSURE, prices=[], headlines=[], opening=[], + tracking={"watches": [], "price_watches": [], "topics": []}, + fetched={}), token="t", image_url="i", page_url="p") + assert "H 94-110" in html + + def test_a_settled_high_stays_a_single_number_on_the_page(self): + from palmer import page + html = page.render(dict(self.SURE, prices=[], headlines=[], opening=[], + tracking={"watches": [], "price_watches": [], "topics": []}, + fetched={}), token="t", image_url="i", page_url="p") + assert "H 90" in html and "H 90-" not in html + + +class TestTheAuditLog: + def test_a_forecast_and_its_actual_score(self, fresh_db): + db.record_forecast("Woodland Hills", "2026-08-27", "nws", 111.0) + db.record_forecast("Woodland Hills", "2026-08-27", "ecmwf_ifs025", 100.6) + db.record_actual("Woodland Hills", "2026-08-27", 99.5) + scores = {r["source"]: r for r in db.forecast_scores(days=3650)} + assert round(scores["nws"]["bias"], 1) == 11.5 + assert round(scores["ecmwf_ifs025"]["bias"], 1) == 1.1 + + def test_logging_the_same_day_twice_does_not_double_count(self, fresh_db): + """The job may be re-run or misfire-recovered; a duplicated day would + silently weight one day twice in the average.""" + for _ in range(3): + db.record_forecast("Culver City", "2026-08-27", "nws", 90.0) + db.record_actual("Culver City", "2026-08-27", 88.3) + assert db.forecast_scores(days=3650)[0]["n"] == 1 + + def test_pending_actuals_lists_only_unfilled_past_days(self, fresh_db): + db.record_forecast("A", "2026-08-25", "nws", 100.0) + db.record_forecast("A", "2026-08-26", "nws", 100.0) + db.record_actual("A", "2026-08-25", 99.0) + assert db.pending_actuals("2026-08-27") == [("A", "2026-08-26")] + + def test_an_unscored_day_is_excluded_from_the_average(self, fresh_db): + """A forecast with no actual yet must not read as a zero-error day.""" + db.record_forecast("A", "2026-08-26", "nws", 100.0) + assert db.forecast_scores(days=3650) == [] + + +class TestTheSelectorIsGatedOnEvidence: + """The audit stopped being a diagnostic once it could act. But the whole + point is that it only acts when the evidence is unambiguous — a selector + that switches on one good day is the anecdote-fitting this was built to + replace.""" + + def _seed(self, fresh_db, rows): + from datetime import date, timedelta + wxaudit._clear_best_cache() + for city, source, err, n in rows: + for i in range(n): + day = (date.today() - timedelta(days=i + 1)).isoformat() + db.record_forecast(city, day, source, 100.0 + err) + db.record_actual(city, day, 100.0) + + def test_a_clearly_better_source_wins(self, fresh_db): + self._seed(fresh_db, + [("WH", "nws", 11, 6), ("WH", "ecmwf_ifs025", 3, 6)]) + assert wxaudit.best_source("WH") == "ecmwf_ifs025" + + def test_an_incumbent_that_is_already_best_is_kept(self, fresh_db): + self._seed(fresh_db, + [("CC", "nws", 2, 6), ("CC", "ecmwf_ifs025", 12, 6)]) + assert wxaudit.best_source("CC") is None + + def test_a_margin_too_thin_does_not_churn(self, fresh_db): + """A source that changes weekly is its own kind of wrong.""" + self._seed(fresh_db, + [("TIE", "nws", 5, 6), ("TIE", "ecmwf_ifs025", 4, 6)]) + assert wxaudit.best_source("TIE") is None + + def test_a_challenger_with_too_few_days_does_not_win(self, fresh_db): + self._seed(fresh_db, + [("THIN", "nws", 11, 6), ("THIN", "icon_seamless", 1, 2)]) + assert wxaudit.best_source("THIN") is None + + def test_an_unmeasured_incumbent_is_never_abandoned(self, fresh_db): + """The failure this gate exists to prevent: NWS has no historical + endpoint, so it starts with almost no scored days. Switching away from + it before measuring it would be exactly the mistake.""" + self._seed(fresh_db, [("NOBASE", "ecmwf_ifs025", 1, 6)]) + assert wxaudit.best_source("NOBASE") is None + + def test_the_answer_is_cached_per_city_per_day(self, fresh_db): + """Consulted on the read path; the answer cannot change intraday.""" + self._seed(fresh_db, + [("WH", "nws", 11, 6), ("WH", "ecmwf_ifs025", 3, 6)]) + wxaudit.best_source("WH") + with patch("palmer.db.forecast_scores", side_effect=AssertionError("should not re-query")): + assert wxaudit.best_source("WH") == "ecmwf_ifs025" + + def test_no_city_and_no_data_are_safe(self, fresh_db): + self._seed(fresh_db, []) + assert wxaudit.best_source("") is None + assert wxaudit.best_source("Nowhere") is None + + +class TestAProvenSourceIsUsedAndTrusted: + def test_the_snapshot_switches_to_the_proven_model(self): + with patch("palmer.wxaudit.best_source", return_value="ecmwf_ifs025"), \ + patch.object(weather, "_geocode", return_value=(34.17, -118.61, "Woodland Hills")), \ + patch.object(weather, "_fetch_openmeteo", return_value={ + "current": {"temperature_2m": 80, "weather_code": 0}, + "daily": {"temperature_2m_max": [96.7], "temperature_2m_min": [69.0], + "precipitation_probability_max": [0], "wind_gusts_10m_max": [5]}}) as om, \ + patch.object(weather, "_nws_snapshot") as nws: + snap = weather.weather_snapshot("Woodland Hills, California") + assert snap["source"] == "ecmwf_ifs025" and snap["high"] == 96.7 + assert om.call_args.kwargs["model"] == "ecmwf_ifs025" + nws.assert_not_called(), "a proven source must not also pay for NWS" + + def test_a_proven_source_is_stated_not_hedged(self): + """The other models disagreeing is what put this one in front; it is no + longer a reason to hedge.""" + out = weather._ensemble_spread(34.17, -118.61, 96.7, proven=True) + assert out["high_confident"] is True + + def test_a_proven_source_costs_no_ensemble_call(self): + with patch.object(weather, "_http_get_json_retry") as http: + weather._ensemble_spread(34.17, -118.61, 96.7, proven=True) + http.assert_not_called() + + def test_nothing_proven_leaves_todays_behaviour_alone(self): + with patch("palmer.wxaudit.best_source", return_value=None), \ + patch.object(weather, "_geocode", return_value=(34.17, -118.61, "WH")), \ + patch.object(weather, "_nws_snapshot", return_value={"high": 103, "source": "nws"}), \ + patch.object(weather, "_ensemble_spread", return_value={"high_confident": False}): + snap = weather.weather_snapshot("Woodland Hills, California") + assert snap["source"] == "nws" and snap["high_confident"] is False + + def test_a_broken_audit_never_costs_anyone_their_forecast(self): + with patch("palmer.wxaudit.best_source", side_effect=RuntimeError("db down")): + assert weather._proven_source("Anywhere") is None + + +class TestTheAuditJobIsSafe: + def test_it_never_raises(self): + with patch("palmer.wxaudit._cities", side_effect=RuntimeError("db down")): + from palmer import wxaudit + wxaudit.run_forecast_audit() # must not propagate + + def test_it_sends_nothing(self): + import inspect + from palmer import wxaudit + src = inspect.getsource(wxaudit) + for forbidden in ("send_sms", "ensure_sms", "messages.create"): + assert forbidden not in src, "the audit is observation only" + + def test_one_city_is_logged_once_however_many_users_share_it(self): + from palmer import wxaudit + profiles = [("+1", {"city": "Kirkwood, MO"}), ("+2", {"city": "Kirkwood, MO"}), + ("+3", {"city": "Culver City"}), ("+4", {})] + with patch("palmer.wxaudit.get_all_profiles", return_value=profiles), \ + patch("palmer.weather._geocode", side_effect=lambda c: (1.0, 2.0, c)): + assert sorted(wxaudit._cities()) == ["Culver City", "Kirkwood, MO"] + + def test_a_scheduled_cron_not_an_interval(self): + """Once a day on an interval makes the phase a function of deploy + history, and a skipped day is a hole in the record.""" + import inspect + from palmer import main + src = inspect.getsource(main) + block = src.split("run_forecast_audit,")[1][:120] + assert '"cron"' in block and "misfire_grace_time" in block diff --git a/tests/test_weather_city.py b/tests/test_weather_city.py deleted file mode 100644 index d502786..0000000 --- a/tests/test_weather_city.py +++ /dev/null @@ -1,143 +0,0 @@ -"""The weather city: written where the user sets it, named where it was measured. - -A user in Culver City got three consecutive mornings of Los Angeles -temperatures — 98, 100, 102 — against local highs of 88, 89, 90. weather.py was -innocent throughout; it faithfully forecast whatever city it was handed. Two -independent defects stacked: - -1. WRITE. He set his weather location by saying "I want the weather updates to - be specific to Culver City California". That routes to - update_morning_briefing, which wrote the topic string and nothing else, so - profile["city"] kept its older, broader value — and profile["city"] is the - sole input to every weather pull Palmer makes. EXTRACT_PROMPT could not - cover it: LOCATION PRECISION only writes city from a statement of residence - or an explicit correction, and a weather preference is neither. - -2. READ. The drafter was handed "Weather in Los Angeles: high 102" and still - wrote "102 in Culver City today", reconciling the number against the - Culver City strings all over the profile in its system prompt. Nothing - stopped it: the line prompt's only data rule was about numbers. The text - briefing has had the city rule since the beginning (see morning.py's - "name the city the forecast is for") — the one-line path never inherited it. - -Defect 1 governs how often the data is wrong. Defect 2 governs what a wrong -value can do: a number under the wrong city name is unfalsifiable from the -message, where "102 in Los Angeles" is read as wrong in one second. Both are -tested here, because fixing only the first leaves the next unenumerated write -path free to produce the same confident lie. -""" -from unittest.mock import patch - -from palmer import agent -from palmer import morning - - -def _dispatch_block() -> str: - """The update_morning_briefing arm of the tool loop, as source.""" - import inspect - src = inspect.getsource(agent.get_reply) - return src.split('update_morning_briefing"')[1].split("elif b.name")[0] - - -class TestCityFromWeatherTopic: - def test_a_weather_topic_yields_its_city(self): - with patch("palmer.morning._infer_city_from_topics", return_value="Culver City, CA") as m: - assert agent._city_from_weather_topic("Culver City CA weather") == "Culver City, CA" - assert m.call_args[0][0] == ["Culver City CA weather"] - - def test_forecast_and_temperature_phrasings_also_count(self): - with patch("palmer.morning._infer_city_from_topics", return_value="Woodland Hills, CA"): - for t in ("Woodland Hills CA weather forecast", "Denver temperature"): - assert agent._city_from_weather_topic(t) == "Woodland Hills, CA" - - def test_a_non_weather_topic_never_pays_for_a_lookup(self): - """This runs on the write path, but a model call per added topic is - still worth avoiding — and a news topic must never move the city.""" - with patch("palmer.llm.client") as client: - for t in ("AI news", "US politics", "Bitcoin price", "SPCX stock price"): - assert agent._city_from_weather_topic(t) is None - client.messages.create.assert_not_called() - - def test_empty_input_is_safe(self): - assert agent._city_from_weather_topic("") is None - assert agent._city_from_weather_topic(None) is None - - def test_an_unresolvable_weather_topic_leaves_the_city_alone(self): - with patch("palmer.morning._infer_city_from_topics", return_value=None): - assert agent._city_from_weather_topic("weather") is None - - -class TestTheAddPathWritesTheCity: - """Where the user sets their weather location is where it has to be saved.""" - - def test_the_add_path_derives_a_city(self): - assert "_city_from_weather_topic" in _dispatch_block() - - def test_the_derived_city_is_actually_written(self): - block = _dispatch_block() - assert '"city"' in block, "deriving the city and not saving it is the original bug" - - def test_a_moved_city_expires_the_cached_forecast(self): - """The page caches weather for 10 minutes. Without expiring it, the - correction is followed by the old city's forecast under the new name — - the precise pairing that made this a lie rather than a stale number.""" - block = _dispatch_block() - assert "weather" in block and "invalidate" in block - - def test_it_does_not_move_their_timezone(self): - """city doubles as the timezone source, but timezone is only derived - when absent. Re-deriving here would shift the hour their morning - arrives as a side effect of correcting a forecast.""" - assert "timezone" not in _dispatch_block() - - -class TestTheForecastNamesWhereItWasMeasured: - """`resolved` comes back from the same geocode that produced the numbers, - so the name and the number cannot disagree.""" - - DRIFTED = { - # profile already corrected; the 10-minute weather stamp has not lapsed - "city": "Culver City", - "weather": {"resolved": "Los Angeles, California", "temp_now": 74.5, - "high": 101.9, "low": 71.5, "description": "clear sky"}, - } - - def test_the_digest_names_the_measured_city_not_the_profiles(self): - d = morning._payload_digest(self.DRIFTED) - assert "Los Angeles, California" in d - assert "Culver City" not in d, "a Los Angeles number must not carry the Culver City name" - - def test_the_temperature_still_rides_with_it(self): - assert "high 102" in morning._payload_digest(self.DRIFTED) - - def test_it_falls_back_to_the_profile_city_when_unresolved(self): - d = morning._payload_digest( - {"city": "Kirkwood, MO", "weather": {"high": 88, "low": 64, "description": "clear"}}) - assert "Kirkwood, MO" in d - - def test_neither_name_leaves_the_line_cityless(self): - d = morning._payload_digest({"weather": {"high": 88, "low": 64, "description": "clear"}}) - assert "their city" in d - - -class TestTheLineIsToldNotToRenameTheCity: - def test_the_prompt_carries_the_city_rule(self): - """The text briefing has always had this rule; the one-line path that - replaced it as the daily send did not inherit it.""" - calls = [] - - def _create(**kw): - calls.append(kw) - return type("R", (), {"content": [type("B", (), {"text": "Cool 71 and clear."})()]})() - - # Patch where the function lives, not where it was defined — morning - # binds `client` at import (CLAUDE.md, "Patching in tests follows the - # code, not the name"). A dead target here would make a real API call. - with patch.object(morning, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch.object(morning, "_build_system", return_value="sys"), \ - patch.object(morning, "_recent_assistant_texts", return_value=[]), \ - patch.object(morning.client.messages, "create", side_effect=_create): - morning.generate_morning_line("+1555", TestTheForecastNamesWhereItWasMeasured.DRIFTED) - body = calls[0]["messages"][0]["content"].lower() - assert "name the city" in body - assert "data wins" in body diff --git a/tests/test_weather_locations.py b/tests/test_weather_locations.py deleted file mode 100644 index f2f4b92..0000000 --- a/tests/test_weather_locations.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Multiple weather locations on Palmer Home. - -profile["city"] stays the one true primary location — the tools, the morning -send, the timezone derivation all still key off it exactly as before. -weather_locations is a small additive list of SECONDARY places pinned to the -page only, following the same write-once-resolve, cap, and cache-invalidate -shape as follow_show/follow_team (see agent.get_reply's add_weather_location -and remove_weather_location blocks). -""" -import inspect -from unittest.mock import patch - -from palmer import agent -from palmer import home -from palmer import page -from palmer import weather - - -class TestResolveWeatherLocation: - def test_resolves_to_the_geocoded_display_form(self): - with patch.object(weather, "_geocode", return_value=(38.5, -90.0, "Holiday Shores, IL")): - assert weather.resolve_weather_location("holiday shores") == "Holiday Shores, IL" - - def test_an_unresolvable_place_returns_none_rather_than_guessing(self): - with patch.object(weather, "_geocode", side_effect=ValueError("Location not found: asdf")): - assert weather.resolve_weather_location("asdfqwer") is None - - -class TestSchema: - def test_the_field_is_allowed(self): - from palmer.userprofile import PROFILE_FIELDS - assert "weather_locations" in PROFILE_FIELDS - - def test_it_is_not_aliased(self): - from palmer.userprofile import _PROFILE_ALIASES - assert "weather_locations" not in _PROFILE_ALIASES - - def test_structured_rows_survive_canonicalisation(self): - from palmer.userprofile import _canonical_updates - out = _canonical_updates({"weather_locations": ["Holiday Shores, IL"]}) - assert out == {"weather_locations": ["Holiday Shores, IL"]} - - -def _block(tool_name: str) -> str: - src = inspect.getsource(agent.get_reply) - return src.split(f'"{tool_name}"')[1].split("elif b.name")[0] - - -class TestSamePlace: - """_label appends the country outside the US now. Rows pinned before that - read "Paris, Île-de-France"; a strict compare pinned the same city twice.""" - - def test_a_legacy_label_matches_its_country_suffixed_form(self): - assert weather.same_place("Paris, Île-de-France", "Paris, Île-de-France, France") - assert weather.same_place("paris, île-de-france, france", "Paris, Île-de-France") - - def test_a_different_place_with_the_same_name_does_not(self): - assert not weather.same_place("Paris, Texas", "Paris, Île-de-France, France") - assert not weather.same_place("Paris", "Paris, Île-de-France, France") - assert not weather.same_place("", "Paris, Île-de-France, France") - - def test_the_dispatch_uses_it(self): - assert "same_place(loc, resolved)" in _block("add_weather_location") - - -class TestAddDispatch: - def test_resolution_happens_on_the_write_path(self): - assert "resolve_weather_location" in _block("add_weather_location") - - def test_an_unresolvable_place_asks_rather_than_guesses(self): - block = _block("add_weather_location") - assert "confirm the city and state" in block - assert "do not guess" in block - - def test_it_is_capped(self): - assert "WEATHER_LOCATIONS_MAX" in _block("add_weather_location") - - def test_it_cannot_duplicate_the_primary_city(self): - block = _block("add_weather_location") - assert 'profile.get("city")' in block - - def test_it_expires_the_cached_section(self): - block = _block("add_weather_location") - assert "invalidate" in block and "weather_extra" in block - - def test_it_never_touches_the_primary_city_field(self): - """Distinguishes this from update_morning_briefing's weather-topic - path, which does write profile["city"].""" - block = _block("add_weather_location") - assert '"city":' not in block and 'updates["city"]' not in block - - -class TestRemoveDispatch: - def test_it_matches_on_text_match_and_falls_back_to_dropping_all(self): - block = _block("remove_weather_location") - assert "text_match" in block - - def test_it_expires_the_cached_section(self): - block = _block("remove_weather_location") - assert "invalidate" in block and "weather_extra" in block - - -class TestHomeFetch: - PROFILE = {"city": "Kirkwood, MO", "timezone": "America/Chicago", - "weather_locations": ["Holiday Shores, IL", "Ballwin, MO"]} - - def test_no_locations_is_not_an_error(self): - assert home._fetch_weather_extra({"city": "Kirkwood, MO"}) == [] - - def test_one_snapshot_per_location(self): - with patch("palmer.weather.weather_snapshot", return_value={"temp_now": 80}) as snap: - out = home._fetch_weather_extra(self.PROFILE) - assert len(out) == 2 - assert snap.call_count == 2 - - def test_a_failed_location_drops_only_that_row(self): - """Same shape as _fetch_prices keeping other tickers when one 429s — - one bad location must not blank the whole section.""" - with patch("palmer.weather.weather_snapshot", side_effect=[None, {"temp_now": 75}]): - out = home._fetch_weather_extra(self.PROFILE) - assert len(out) == 1 - assert out[0]["temp_now"] == 75 - - def test_the_window_does_not_alias_against_the_daily_send(self): - assert home.STALE["weather_extra"] < 24 * 3600 - - -class TestPageRenders: - BASE = {"city": "Kirkwood, MO", "weather": {"temp_now": 81.0, "description": "Clear"}, - "fetched": {}, "tracking": {}} - - def _render(self, **over): - payload = dict(self.BASE) - payload.update(over) - return page.render(payload, token="t", image_url="i", page_url="p") - - def test_absent_when_there_are_no_extra_locations(self): - assert "Weather" not in self._render() - - def test_each_extra_location_shows_its_place_and_temp(self): - html = self._render(weather_extra=[ - {"resolved": "Holiday Shores, IL", "temp_now": 82.0, "description": "sunny"}]) - assert "Holiday Shores, IL" in html - assert "82°" in html - - def test_the_label_is_a_single_word(self): - """See test_page.py::TestSectionLabelsAreOneWord — every card label on - the page must be one word.""" - html = self._render(weather_extra=[{"resolved": "Holiday Shores, IL", "temp_now": 82.0}]) - assert "
Weather<" in html - - -class TestAnAmbiguousPlaceIsAskedAboutNotGuessed: - """_geocode asked for count=1 and took results[0], so Springfield, - Portland, Columbus and Cambridge each resolved to whichever the geocoder - ranked first — silently, and then confirmed to the user as though they had - named it. resolve_weather_location's docstring already claimed "None means - the model should ask rather than guess", which was only ever true when - NOTHING matched.""" - - def _payload(self, *rows): - return {"results": list(rows)} - - SPRINGFIELD_IL = {"name": "Springfield", "admin1": "Illinois", - "country": "United States", "latitude": 39.8, "longitude": -89.6} - SPRINGFIELD_MO = {"name": "Springfield", "admin1": "Missouri", - "country": "United States", "latitude": 37.2, "longitude": -93.3} - KIRKWOOD = {"name": "Kirkwood", "admin1": "Missouri", - "country": "United States", "latitude": 38.6, "longitude": -90.4} - - def _clear(self): - weather._geocode_cache.clear() - weather._geocode_alts.clear() - - def test_two_real_places_with_one_name_is_a_question(self): - self._clear() - with patch.object(weather, "_http_get_json_retry", - return_value=self._payload(self.SPRINGFIELD_IL, self.SPRINGFIELD_MO)): - assert weather.ambiguous_location("Springfield") == [ - "Springfield, Illinois", "Springfield, Missouri"] - - def test_one_match_is_not(self): - self._clear() - with patch.object(weather, "_http_get_json_retry", - return_value=self._payload(self.KIRKWOOD)): - assert weather.ambiguous_location("Kirkwood") == [] - - def test_a_qualified_name_is_not(self): - """They already told you which one. Asking again is the annoying kind - of careful.""" - self._clear() - assert weather.ambiguous_location("Springfield, IL") == [] - - def test_the_read_path_still_takes_the_top_hit(self): - """count went 1 -> 5 on the same call. The extra rows are for the - write paths; nothing about resolution changed.""" - self._clear() - with patch.object(weather, "_http_get_json_retry", - return_value=self._payload(self.SPRINGFIELD_IL, self.SPRINGFIELD_MO)): - lat, lon, resolved = weather._geocode("Springfield") - assert resolved == "Springfield, Illinois" - assert (lat, lon) == (39.8, -89.6) - - def test_the_dispatch_refuses_to_pick(self): - import inspect - from palmer import agent - block = inspect.getsource(agent.get_reply).split('"add_weather_location"')[1] \ - .split("elif b.name")[0] - assert "ambiguous_location" in block - assert "Do NOT" in block and "pick one yourself" in block diff --git a/tests/test_weather_source.py b/tests/test_weather_source.py deleted file mode 100644 index 2e34570..0000000 --- a/tests/test_weather_source.py +++ /dev/null @@ -1,164 +0,0 @@ -"""One source per user: NWS where it reaches, Open-Meteo everywhere else. - -The page and the chat reply used to come from different forecasters. Chat asked -NWS; the page, the card and the morning line asked Open-Meteo. For a coastal -city that is not a rounding difference — on one August day in Culver City the -raw models spread 15 degrees for the same point (MeteoFrance 83, JMA 82, ICON -90, GEM 94, GFS 96, ECMWF 97, OpenWeatherMap 96), because how far the marine -layer pushes inland decides the answer. NWS said 90, Google said 87, and the -page showed 96 while the same user asking in the thread was told 90. - -NWS is a forecaster product rather than a raw model — the local office corrects -for terrain and marine layer — so it is the better number wherever it exists. -Open-Meteo stays as the fallback because it is the only one of the two that -covers anywhere outside the US, and because NWS does go down. - -Every test here is offline. A live call in this file is the suite-runtime -regression CLAUDE.md warns about, and it is easy to reintroduce: patching -_fetch_openmeteo no longer keeps a US location off the network. -""" -from unittest.mock import patch - -from palmer import weather - - -POINTS = { - "forecast": "https://api.weather.gov/gridpoints/LOX/149,45/forecast", - "forecastHourly": "https://api.weather.gov/gridpoints/LOX/149,45/forecast/hourly", - "forecastGridData": "https://api.weather.gov/gridpoints/LOX/149,45", -} -FORECAST = {"properties": {"periods": [ - {"startTime": "2026-08-27T06:00:00-07:00", "isDaytime": True, "temperature": 90, - "windSpeed": "5 to 10 mph", "shortForecast": "Sunny", - "probabilityOfPrecipitation": {"value": 20}}, - {"startTime": "2026-08-27T18:00:00-07:00", "isDaytime": False, "temperature": 71, - "windSpeed": "0 to 5 mph", "shortForecast": "Clear", - "probabilityOfPrecipitation": {"value": 0}}, -]}} -HOURLY = {"properties": {"periods": [ - {"temperature": 73, "relativeHumidity": {"value": 68}, "windSpeed": "3 mph", - "shortForecast": "Mostly Cloudy"}, -]}} -GRID = {"properties": { - "apparentTemperature": {"uom": "wmoUnit:degC", - "values": [{"validTime": "1999-01-01T00:00:00+00:00/PT1H", "value": 25.0}]}, - "windGust": {"uom": "wmoUnit:km_h-1", "values": []}, -}} - - -def _route(url, **kw): - if "/forecast/hourly" in url: - return HOURLY - if url.endswith("/forecast"): - return FORECAST - return GRID - - -def _nws_offline(): - """Patch every NWS hop. The points cache is cleared so a previous test's - real lookup can't leak in.""" - weather._nws_points_cache.clear() - return patch.multiple( - weather, - _nws_points=lambda lat, lon: POINTS, - _http_get_json_retry=_route, - ) - - -class TestUsGoesToNws: - def test_a_us_location_uses_nws(self): - with _nws_offline(), patch.object( - weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")): - s = weather.weather_snapshot("Culver City", "America/Los_Angeles") - assert s["source"] == "nws" - assert s["high"] == 90 and s["low"] == 71, "the forecaster's number, not a raw model's" - - def test_it_carries_every_field_the_card_and_page_render(self): - with _nws_offline(), patch.object( - weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")): - s = weather.weather_snapshot("Culver City", "America/Los_Angeles") - # cards.py and page.py format these with :.0f — a string would raise - for k in ("temp_now", "high", "low", "wind", "feels_like"): - assert isinstance(s[k], (int, float)), f"{k} must be numeric, got {s[k]!r}" - assert s["description"] and s["resolved"] == "Culver City, California" - - def test_prose_wind_becomes_a_number(self): - """NWS writes wind as "5 to 10 mph"; the top of the range is what people - plan around, and the renderers need a number either way.""" - assert weather._mph("5 to 10 mph") == 10.0 - assert weather._mph("0 mph") == 0.0 - assert weather._mph(None) is None - assert weather._mph("calm") is None - - def test_celsius_and_kmh_are_converted(self): - assert round(weather._c_to_f(25.0)) == 77 - assert round(weather._kmh_to_mph(16.0)) == 10 - assert weather._c_to_f(None) is None and weather._kmh_to_mph(None) is None - - -class TestFallback: - def test_outside_the_us_uses_open_meteo(self): - payload = {"current": {"temperature_2m": 77.8, "apparent_temperature": 82.0, - "weather_code": 3, "wind_speed_10m": 4.3, - "relative_humidity_2m": 60}, - "daily": {"temperature_2m_max": [81.0], "temperature_2m_min": [67.5], - "precipitation_probability_max": [75], - "wind_gusts_10m_max": [21.0]}} - with patch.object(weather, "_geocode", return_value=(48.86, 2.35, "Paris")), \ - patch.object(weather, "_fetch_openmeteo", return_value=payload) as om: - s = weather.weather_snapshot("Paris", "Europe/Paris") - assert s["source"] == "open-meteo" and om.called - assert s["high"] == 81.0 - - def test_an_nws_outage_falls_back_rather_than_failing(self): - """NWS goes down. A US user gets Open-Meteo's number, not an empty page.""" - payload = {"current": {"temperature_2m": 70.0, "apparent_temperature": 70.0, - "weather_code": 0, "wind_speed_10m": 3.0, - "relative_humidity_2m": 50}, - "daily": {"temperature_2m_max": [88.0], "temperature_2m_min": [64.0], - "precipitation_probability_max": [10], - "wind_gusts_10m_max": [9.0]}} - weather._nws_points_cache.clear() - with patch.object(weather, "_geocode", return_value=(38.5, -90.4, "Kirkwood, Missouri")), \ - patch.object(weather, "_nws_snapshot", side_effect=RuntimeError("NWS 503")), \ - patch.object(weather, "_fetch_openmeteo", return_value=payload): - s = weather.weather_snapshot("Kirkwood, MO") - assert s["source"] == "open-meteo" and s["high"] == 88.0 - - def test_a_total_failure_still_returns_none(self): - with patch.object(weather, "_geocode", side_effect=RuntimeError("boom")): - assert weather.weather_snapshot("Nowhere") is None - - -class TestGridpointExtrasAreOptional: - def test_a_gridpoint_failure_drops_the_chip_not_the_forecast(self): - """feels_like and gusts are chips. The high is the message.""" - def _flaky(url, **kw): - if url.endswith("/forecast"): - return FORECAST - if "/forecast/hourly" in url: - return HOURLY - raise RuntimeError("gridpoint 500") - weather._nws_points_cache.clear() - with patch.object(weather, "_geocode", return_value=(34.02, -118.39, "Culver City, California")), \ - patch.multiple(weather, _nws_points=lambda lat, lon: POINTS, - _http_get_json_retry=_flaky): - s = weather.weather_snapshot("Culver City", "America/Los_Angeles") - assert s["source"] == "nws" and s["high"] == 90 - assert s["feels_like"] is None and s["gusts"] is None - - -class TestPointsCache: - def test_the_grid_lookup_is_cached_like_the_geocode(self): - """Grid cells don't move. This saves a round trip on every refresh.""" - weather._nws_points_cache.clear() - calls = [] - - def _points(url, **kw): - calls.append(url) - return {"properties": dict(POINTS)} - - with patch.object(weather, "_http_get_json_retry", _points): - weather._nws_points(34.02, -118.39) - weather._nws_points(34.02, -118.39) - assert len(calls) == 1