diff --git a/applyr/commands/analytics.py b/applyr/commands/analytics.py index 0a24bc1..2b12ff1 100644 --- a/applyr/commands/analytics.py +++ b/applyr/commands/analytics.py @@ -96,7 +96,10 @@ def cmd_pipeline(min_score: int = 0, as_json: bool = False) -> None: conn.close() if not rows: - print("No offers in the database.") + if as_json: + print(json.dumps({status: [] for status in _STATUS_ORDER}, indent=2, ensure_ascii=False)) + else: + print("No offers in the database.") return # Group by status @@ -294,7 +297,10 @@ def cmd_gaps(limit: int = 10, as_json: bool = False) -> None: rows = _live_skill_gaps(limit) if not rows: - print("No skill gaps recorded yet.") + if as_json: + print(json.dumps([], indent=2, ensure_ascii=False)) + else: + print("No skill gaps recorded yet.") return worst_gap = max(r["total_gap"] for r in rows) @@ -438,7 +444,10 @@ def cmd_trends(period: str = "week", as_json: bool = False) -> None: conn.close() if not rows: - print("No dated offers found.") + if as_json: + print(json.dumps([], indent=2, ensure_ascii=False)) + else: + print("No dated offers found.") return if as_json: @@ -660,7 +669,10 @@ def cmd_plan(limit: int = 10, as_json: bool = False) -> None: rows = _live_skill_gaps() if not rows: - print("No skill gaps recorded yet.") + if as_json: + print(json.dumps([], indent=2, ensure_ascii=False)) + else: + print("No skill gaps recorded yet.") return # `total_gap` is already frequency times average gap — the points a topic @@ -724,7 +736,10 @@ def cmd_salary(seniority: str | None = None, category: str | None = None, as_jso conn.close() if not rows: - print("No salary data available.") + if as_json: + print(json.dumps({"by_seniority": [], "by_category": []}, indent=2, ensure_ascii=False)) + else: + print("No salary data available.") return # Group by seniority diff --git a/applyr/commands/core.py b/applyr/commands/core.py index fe349a5..45f08b6 100644 --- a/applyr/commands/core.py +++ b/applyr/commands/core.py @@ -785,10 +785,13 @@ def cmd_list(status_filter: str | None = None, sort_by: str = "date_applied", li conn.close() if not rows: - msg = f"No offers found" - if status_filter: - msg += f" with status '{status_filter}'" - print(msg + ".") + if as_json: + print(json.dumps([], indent=2, ensure_ascii=False)) + else: + msg = f"No offers found" + if status_filter: + msg += f" with status '{status_filter}'" + print(msg + ".") return if as_json: @@ -1125,7 +1128,10 @@ def cmd_search(keyword: str, status_filter: str | None = None, company: str | No target = f"company '{company}'" if company else f"'{keyword}'" if not rows: - print(f"No offers found matching {target}.") + if as_json: + print(json.dumps([], indent=2, ensure_ascii=False)) + else: + print(f"No offers found matching {target}.") return if as_json: diff --git a/applyr/duplicates.py b/applyr/duplicates.py index 5791f4c..4d00ed6 100644 --- a/applyr/duplicates.py +++ b/applyr/duplicates.py @@ -13,6 +13,7 @@ import re import sqlite3 +import unicodedata from difflib import SequenceMatcher from applyr.constants import DUPLICATE_SIMILARITY_THRESHOLD @@ -58,13 +59,37 @@ def title_similarity(a: str, b: str) -> float: return SequenceMatcher(None, norm_a, norm_b).ratio() +def normalize_company(text: str | None) -> str: + """Lowercase and strip diacritics, so "Mática" and "Matica" compare equal. + + Company names get typed inconsistently far more often than two + genuinely different companies happen to share a name — a posting + pasted from LinkedIn, OCR, or a rushed manual entry drops accents far + more often than it invents a real collision. Unlike substring or fuzzy + matching (rejected for company names — see module docstring), stripping + diacritics never merges two different companies together, so it carries + none of that false-positive risk. + """ + if not text: + return "" + decomposed = unicodedata.normalize("NFKD", text) + return "".join(c for c in decomposed if not unicodedata.combining(c)).lower() + + +def _register_company_normalizer(conn: sqlite3.Connection) -> None: + """Expose normalize_company() to SQL as unaccent_lower(). Idempotent — + re-registering the same function on a connection is safe and cheap.""" + conn.create_function("unaccent_lower", 1, normalize_company) + + def find_exact(conn: sqlite3.Connection, title: str, company: str | None) -> sqlite3.Row | None: - """Find an offer with the same title and company, case-insensitively.""" + """Find an offer with the same title and company, case/accent-insensitively.""" + _register_company_normalizer(conn) return conn.execute( """SELECT id, title, status, date_received, compatibility_pct FROM offers WHERE LOWER(title) = LOWER(?) - AND LOWER(COALESCE(company,'')) = LOWER(COALESCE(?,''))""", + AND unaccent_lower(COALESCE(company,'')) = unaccent_lower(COALESCE(?,''))""", (title, company), ).fetchone() @@ -73,10 +98,11 @@ def find_company_offers(conn: sqlite3.Connection, company: str | None) -> list[s """All offers already recorded for a company, newest first.""" if not company: return [] + _register_company_normalizer(conn) return conn.execute( """SELECT id, title, status, date_received, compatibility_pct FROM offers - WHERE LOWER(COALESCE(company,'')) = LOWER(?) + WHERE unaccent_lower(COALESCE(company,'')) = unaccent_lower(?) ORDER BY id DESC""", (company,), ).fetchall() diff --git a/tests/test_cli_routing.py b/tests/test_cli_routing.py index 29b682e..59b0bd3 100644 --- a/tests/test_cli_routing.py +++ b/tests/test_cli_routing.py @@ -438,13 +438,9 @@ def test_json_no_color_combined(self, run_cli, capsys, tmp_db): def test_json_flag_gaps(self, run_cli, capsys, tmp_db): out, err, code = _run(run_cli, capsys, ["--json", "gaps"]) assert code == 0 - # With empty DB, gaps may print a message or empty JSON - if out.strip(): - try: - data = json.loads(out) - assert isinstance(data, dict) - except json.JSONDecodeError: - pass # Some commands print messages instead of JSON when empty + # cmd_gaps's JSON payload is a list of skill-gap entries — empty DB is [] + data = json.loads(out) + assert isinstance(data, list) def test_json_flag_followups(self, run_cli, capsys, tmp_db): out, err, code = _run(run_cli, capsys, ["--json", "followups"]) @@ -479,12 +475,9 @@ def test_json_flag_summary(self, run_cli, capsys, tmp_db): def test_json_flag_trends(self, run_cli, capsys, tmp_db): out, err, code = _run(run_cli, capsys, ["--json", "trends"]) assert code == 0 - if out.strip(): - try: - data = json.loads(out) - assert isinstance(data, dict) - except json.JSONDecodeError: - pass + # cmd_trends's JSON payload is a list of period entries — empty DB is [] + data = json.loads(out) + assert isinstance(data, list) def test_json_flag_doctor(self, run_cli, capsys, tmp_db): out, err, code = _run(run_cli, capsys, ["--json", "doctor"]) diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py index d3d310c..8ee9a0f 100644 --- a/tests/test_duplicates.py +++ b/tests/test_duplicates.py @@ -3,7 +3,14 @@ import pytest from applyr.constants import DUPLICATE_SIMILARITY_THRESHOLD -from applyr.duplicates import find_similar, normalize_title, title_similarity +from applyr.duplicates import ( + find_company_offers, + find_exact, + find_similar, + normalize_company, + normalize_title, + title_similarity, +) class TestNormalizeTitle: @@ -98,3 +105,50 @@ def test_respects_custom_threshold(self): def test_common_posting_variants_are_caught(self, variant): rows = [FakeRow(id=1, title=variant)] assert find_similar(rows, "Backend Engineer") is not None + + +class TestNormalizeCompany: + def test_lowercases(self): + assert normalize_company("Acme Corp") == "acme corp" + + def test_strips_diacritics(self): + assert normalize_company("Mática Partners") == "matica partners" + + def test_already_unaccented_is_unchanged_besides_case(self): + assert normalize_company("Matica Partners") == "matica partners" + + def test_none_becomes_empty_string(self): + assert normalize_company(None) == "" + + +class TestFindExactAndFindCompanyOffers: + """Found via a real offer: "Mática Partners" and "Matica Partners" were + treated as two different companies because the SQL comparison only + lowercased, never stripped accents — so add's duplicate warning and + search --company silently missed real history at the same company. + """ + + @pytest.fixture + def conn(self, tmp_db): + from applyr.db import get_conn + + connection = get_conn(tmp_db) + connection.execute( + "INSERT INTO offers (title, company) VALUES (?, ?)", + ("IA Engineer", "Matica Partners"), + ) + connection.commit() + return connection + + def test_find_company_offers_ignores_accent_differences(self, conn): + rows = find_company_offers(conn, "Mática Partners") + assert len(rows) == 1 + assert rows[0]["title"] == "IA Engineer" + + def test_find_exact_ignores_accent_differences(self, conn): + row = find_exact(conn, "IA Engineer", "Mática Partners") + assert row is not None + + def test_find_exact_still_requires_the_same_title(self, conn): + row = find_exact(conn, "Backend Developer", "Mática Partners") + assert row is None diff --git a/tests/test_empty_results_json.py b/tests/test_empty_results_json.py new file mode 100644 index 0000000..742702a --- /dev/null +++ b/tests/test_empty_results_json.py @@ -0,0 +1,61 @@ +"""--json must stay parseable even when there is nothing to report. + +Found while auditing applyr end to end: every list-shaped command printed a +human sentence ("No offers found.", "No skill gaps recorded yet.", ...) on +an empty result set regardless of --json, breaking the one contract +AGENT_INSTRUCTIONS.md promises agent callers ("--json en todos los +comandos"). cmd_gaps_list already did this right (`{"total": 0, "gaps": []}` +even when empty); the rest are brought in line with it here. +""" + +import json + +import pytest + +from applyr.commands import ( + cmd_gaps, + cmd_list, + cmd_pipeline, + cmd_plan, + cmd_salary, + cmd_search, + cmd_trends, +) + + +@pytest.mark.unit +class TestEmptyResultsStayJson: + + def test_list(self, tmp_db, tmp_applyr, capsys): + cmd_list(as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_search(self, tmp_db, tmp_applyr, capsys): + cmd_search("nothing-matches-this", as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_search_company(self, tmp_db, tmp_applyr, capsys): + cmd_search("", company="Nobody Inc", as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_pipeline(self, tmp_db, tmp_applyr, capsys): + cmd_pipeline(as_json=True) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + assert all(v == [] for v in payload.values()) + + def test_gaps(self, tmp_db, tmp_applyr, capsys): + cmd_gaps(as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_trends(self, tmp_db, tmp_applyr, capsys): + cmd_trends(as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_plan(self, tmp_db, tmp_applyr, capsys): + cmd_plan(as_json=True) + assert json.loads(capsys.readouterr().out) == [] + + def test_salary(self, tmp_db, tmp_applyr, capsys): + cmd_salary(as_json=True) + assert json.loads(capsys.readouterr().out) == {"by_seniority": [], "by_category": []}