Files
ai-job-search/tests/test_rank_command.py
T

163 lines
7.4 KiB
Python
Raw Normal View History

"""Guards for the /rank command spec.
The command is a markdown spec (the spec IS the implementation), so these
tests pin the invariants that would break silently: the header format that
lint_skills.py enforces, and the persistence of scoring-agent gaps/strengths
into seen_jobs.json (previously computed in Step 2 and thrown away after
Step 5's terminal output).
"""
import subprocess
import sys
import unittest
from pathlib import Path
try:
import yaml # noqa: F401 - only probing availability for the lint integration test
_HAVE_YAML = True
except ImportError:
_HAVE_YAML = False
REPO = Path(__file__).resolve().parent.parent
COMMAND = REPO / ".claude" / "commands" / "rank.md"
SCRAPER_SKILL = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
def _sections(text: str) -> dict[str, str]:
"""Split a command spec into {heading: body} by '##' headers.
Splitting this way lets a fork's extra sections (e.g. this fork's
'## Blocker logging') sit between the ones under test without shifting
which text a given assertion sees.
"""
parts = text.split("\n## ")
result = {}
for part in parts[1:]:
heading, _, body = part.partition("\n")
result[heading.strip()] = body
return result
class RankCommandSpec(unittest.TestCase):
def test_command_file_exists_with_lint_compliant_header(self):
self.assertTrue(COMMAND.is_file(), "command spec missing")
first_line = COMMAND.read_text(encoding="utf-8").splitlines()[0]
self.assertTrue(
first_line.startswith("# /rank"),
f"header must start with '# /rank' (lint_skills.py enforces it), got: {first_line!r}",
)
def test_step4_persists_gaps_and_strengths(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step4 = sections.get("Step 4: Update State", "")
self.assertIn('"gaps"', step4, "Step 4 must persist the gaps array into seen_jobs.json")
self.assertIn('"strengths"', step4, "Step 4 must persist the strengths array into seen_jobs.json")
def test_step4_documents_verbatim_no_accumulate_and_untrusted_data_rules(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step4 = sections.get("Step 4: Update State", "")
self.assertIn("verbatim", step4, "Step 4 must require storing gaps/strengths verbatim, never reformatted")
self.assertIn("replaces", step4, "Step 4 must state that --all re-scoring replaces, not accumulates, the arrays")
self.assertIn("untrusted data", step4, "Step 4 must restate that stored gaps/strengths are untrusted data")
def test_important_rules_link_honest_scoring_to_persistence(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
rules = sections.get("Important Rules", "")
self.assertIn(
"persisted with it",
rules,
"Rule 5 must note that gaps are persisted (Step 4), not just printed (Step 5)",
)
def test_job_scraper_schema_note_mentions_strengths_and_gaps(self):
text = SCRAPER_SKILL.read_text(encoding="utf-8")
self.assertIn("strengths", text)
self.assertIn("gaps", text)
self.assertIn(
"readers tolerate their absence",
text,
"schema note must say old entries lacking strengths/gaps are tolerated, never backfilled",
)
def test_step2_schema_includes_language_gate_fields(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step2 = sections.get("Step 2: Batch-Fetch and Score", "")
self.assertIn('"language_gate"', step2, "Step 2's scoring-agent JSON must include language_gate")
self.assertIn('"language_note"', step2, "Step 2's scoring-agent JSON must include language_note")
self.assertIn(
'"PASS" | "FAIL" | "FLAG"',
step2,
"language_gate must use the same PASS/FAIL/FLAG verdict set as the location veto",
)
self.assertIn(
"distinct from",
step2,
"spec must distinguish language_gate/language_note from the pre-existing 'language' field "
"(which records the posting's own language, not a veto verdict) - the two are easy to conflate",
)
def test_step3_documents_language_veto(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step3 = sections.get("Step 3: Aggregate and Rank", "")
self.assertIn(
"Language veto",
step3,
"Step 3 must document a Language veto rule, mirroring the existing Location veto",
)
self.assertIn(
"excludes the job from the shortlist",
step3,
"a language_gate FAIL must be documented as excluding the job, same as a location FAIL",
)
def test_step4_persists_language_gate_and_language_note(self):
"""Regression guard: language_gate/language_note were computed in Step 2 and used
to decide Step 3's veto, but never written to seen_jobs.json - live-debugged and
fixed once already (a real /rank run showed language_gate: null on every entry
despite the run reporting real vetoes). This pins the fix in the spec text the
same way test_step4_persists_gaps_and_strengths pins the sibling strengths/gaps
persistence bug, so a future edit can't silently reintroduce either loss.
"""
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step4 = sections.get("Step 4: Update State", "")
self.assertIn('"language_gate"', step4, "Step 4 must persist language_gate into seen_jobs.json")
self.assertIn('"language_note"', step4, "Step 4 must persist language_note into seen_jobs.json")
self.assertIn(
"as important to persist as the score itself",
step4,
"Step 4 must call out that the veto fields (location/language_gate/language_note) are not optional extras",
)
def test_step5_documents_language_flag_marker(self):
# Note: _sections() splits on every "\n## " line, including the "## Job
# Ranking - YYYY-MM-DD" line inside Step 5's own fenced example template -
# so the presentation rules that follow that example live under that key,
# not "Step 5: Present the Shortlist" itself. Matches how the existing
# gaps/strengths tests above only probe Step 4, never Step 5, for the same
# reason - documented here since it's easy to trip over when adding a new
# Step-5-content test.
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step5_rules = sections.get("Job Ranking - YYYY-MM-DD", "")
self.assertIn(
"language_gate: FLAG",
step5_rules,
"Step 5's presentation rules must document the ⚠ marker + language_note callout "
"for a shortlisted FLAG job, mirroring the existing location FLAG treatment",
)
@unittest.skipUnless(
_HAVE_YAML,
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
)
def test_lint_skills_passes(self):
result = subprocess.run(
[sys.executable, str(REPO / "tools" / "lint_skills.py")],
cwd=REPO,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, f"lint_skills.py failed:\n{result.stdout}{result.stderr}")
if __name__ == "__main__":
unittest.main()