mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
Follow-up suggested during PR #275's merge review: spec-pinning tests for the language_gate/language_note fields, matching the pattern already used for the sibling gaps/strengths fields in this same file. Four new assertions: Step 2's scoring-agent JSON schema declares both fields with the PASS/FAIL/FLAG enum and distinguishes them from the pre-existing `language` field; Step 3 documents the Language veto rule mirroring the existing Location veto; Step 4 persists both fields into seen_jobs.json; Step 5's presentation rules document the FLAG marker. The Step 4 persistence test is a real regression guard, not just documentation: language_gate/language_note were computed in Step 2 and used to decide Step 3's veto, but an earlier version of this spec never instructed Step 4 to actually write them to seen_jobs.json - caught via live debugging (a real /rank run showed language_gate: null on every entry despite the run reporting genuine vetoes), fixed once already. Verified live that this test fails against that exact regression and passes against the current (fixed) spec text.
163 lines
7.4 KiB
Python
163 lines
7.4 KiB
Python
"""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()
|