mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
/apply wrote a CV and a cover letter to disk and then wrote nothing to job_search_tracker.csv, so a drafted and submitted application was invisible to /gmail-sync, /html-report, /notion-sync, /interview, /upskill aggregate mode, and to /rank's dedup exclusion. The safety net that would have caught it - /gmail-sync - refuses to create missing rows, so the failure it exists to catch is the one that disables it. Nothing detected the loss afterwards. Step 6b appends a drafted row carrying the two document paths, the fit rating and the posting URL, reusing /outcome's exact header so the two commands cannot diverge. It runs immediately after "Files Created" and before the optional application-form offer, which ends the turn on a question - anything placed after that offer would be skipped whenever the user never answers, reproducing the bug. Re-running /apply updates the row rather than duplicating it, and never moves a row that already reached applied or beyond back to drafted. The step is mirrored into job-application-assistant, which defers to it rather than restating it, because /scrape Step 5 routes straight into the skill; /scrape Step 6 now defers to the same step instead of adding a row of its own. seen_jobs.json is deliberately left alone: drafting is not applying, and that file's vocabulary has no value for either. /rank builds its exclusion set from company+role in the tracker regardless of status. drafted is introduced into the status vocabulary, and every reader that meant "submitted" is updated to say so. These readers define their open set by exclusion from the final statuses, so a new non-final value would otherwise have joined all of them silently: /outcome's follow-up branch would have drafted a chase email to an employer who never received an application, /gmail-sync would have searched for mail about it and then flagged it as stale, /notion-sync would have published an "Applied on" date for it, and /html-report would have counted it in the headline application total. /outcome Step 4 also overwrites the draft date with the submission date when a row leaves drafted, so the date column keeps meaning "applied on". The wider vocabulary reconciliation - underscore versus space, the separate archive enum - stays a separate concern.
195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""Guards for /apply's tracker recording step (Step 6b).
|
|
|
|
The step is part of the /apply markdown spec (the spec IS the
|
|
implementation), so these tests pin the invariants that would break
|
|
silently. Assertions are scoped to the section they belong to, following
|
|
the pattern in test_upskill_skill.py: a whole-file `assertIn` for a word
|
|
as common as `drafted` passes on any unrelated mention and guards nothing.
|
|
|
|
The CSV header is the one rule most easily lost: it must stay
|
|
byte-identical to /outcome's, which is the entire reason for reusing it.
|
|
How each reader treats `drafted` is pinned per reader below, because the
|
|
right answer differs between them.
|
|
"""
|
|
import re
|
|
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
|
|
COMMANDS = REPO / ".claude" / "commands"
|
|
APPLY = COMMANDS / "apply.md"
|
|
OUTCOME = COMMANDS / "outcome.md"
|
|
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
|
HTML_REPORT = COMMANDS / "html-report.md"
|
|
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
|
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
|
|
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
|
|
|
TRACKER_HEADER = (
|
|
"date,company,sector,role,role_type,channel,status,contact_person,"
|
|
"fit_rating,notes,cv_file,cover_letter_file,source"
|
|
)
|
|
|
|
|
|
def section(path, heading):
|
|
"""The body of one markdown section, up to the next heading of any depth."""
|
|
text = path.read_text(encoding="utf-8")
|
|
start = text.index(heading) + len(heading)
|
|
rest = text[start:]
|
|
end = re.search(r"^#{1,4} ", rest, re.MULTILINE)
|
|
return rest[: end.start()] if end else rest
|
|
|
|
|
|
class ApplyRecordsApplication(unittest.TestCase):
|
|
"""/apply Step 6b writes the row that six other commands read."""
|
|
|
|
def setUp(self):
|
|
self.step_6b = section(APPLY, "### Step 6b: Record the Application")
|
|
|
|
def test_step_writes_a_drafted_row_with_both_document_paths(self):
|
|
for fragment in (
|
|
"| `status` | `drafted` |",
|
|
'| `cv_file`, `cover_letter_file` | the two paths listed under "Files Created"',
|
|
):
|
|
self.assertIn(
|
|
fragment,
|
|
self.step_6b,
|
|
f"Step 6b's column table lost {fragment!r} - the row it writes would "
|
|
"no longer identify itself as a draft or point at the documents",
|
|
)
|
|
|
|
def test_tracker_header_matches_outcome(self):
|
|
"""Byte-identical, or the two commands create incompatible CSVs."""
|
|
self.assertIn(TRACKER_HEADER, OUTCOME.read_text(encoding="utf-8"))
|
|
self.assertIn(
|
|
TRACKER_HEADER,
|
|
self.step_6b,
|
|
"Step 6b's header drifted from outcome.md's - whichever command ran "
|
|
"first would decide the schema",
|
|
)
|
|
|
|
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
|
|
"""The optional application-form offer asks the user a question.
|
|
|
|
Anything after it only runs if the user answers, so recording the
|
|
application there would reproduce the bug this step fixes.
|
|
"""
|
|
text = APPLY.read_text(encoding="utf-8")
|
|
self.assertLess(
|
|
text.index("### Step 6b: Record the Application"),
|
|
text.index("### Application-Form Fields"),
|
|
"Step 6b moved after the optional-artifact offer, which ends the turn "
|
|
"on a question - the tracker row would be skipped whenever the user "
|
|
"never answers",
|
|
)
|
|
|
|
def test_matched_row_is_never_moved_backwards(self):
|
|
self.assertIn(
|
|
"never move it backwards",
|
|
self.step_6b,
|
|
"Step 6b lost the rule protecting a submitted row - re-running /apply "
|
|
"to refresh a CV would reset a live interview back to drafted",
|
|
)
|
|
|
|
def test_redraft_marker_is_undated(self):
|
|
"""/outcome reads the latest dated note as the last activity."""
|
|
self.assertIn(
|
|
"undated `redrafted` marker",
|
|
self.step_6b,
|
|
"a dated redraft marker resets /outcome's days-quiet clock, hiding a "
|
|
"genuinely quiet application from the follow-up offer",
|
|
)
|
|
|
|
def test_seen_jobs_is_left_alone(self):
|
|
self.assertIn(
|
|
"Do not modify `job_scraper/seen_jobs.json`",
|
|
self.step_6b,
|
|
"drafting is not applying, and that file has no honest value for either",
|
|
)
|
|
|
|
def test_skill_defers_to_apply_rather_than_restating(self):
|
|
"""/scrape Step 5 routes into the skill, bypassing /apply entirely."""
|
|
step_3b = section(SKILL, "### Step 3b: Record the Application")
|
|
self.assertIn(
|
|
"`/apply` Step 6b",
|
|
step_3b,
|
|
"the skill's recording step no longer points at the canonical rule, so "
|
|
"the two copies can drift",
|
|
)
|
|
|
|
|
|
class DraftedMeansDraftedToEveryReader(unittest.TestCase):
|
|
"""`drafted` is non-final, so readers that mean *submitted* must say so.
|
|
|
|
Each of these defines its set by exclusion from the final statuses, so
|
|
a new non-final value joins them all silently. The one exception is
|
|
/gmail-sync, which must keep searching for drafted rows: the user
|
|
submitting by hand and not running /outcome is the failure #269 is
|
|
about, and an employer reply is how it gets caught.
|
|
"""
|
|
|
|
CASES = [
|
|
(HTML_REPORT, None, "`drafted` → **Drafted**",
|
|
"a status with no bucket is dropped from every statistic"),
|
|
(HTML_REPORT, "## Step 2: Compute Summary Stats",
|
|
"excluded from every statistic below",
|
|
"the headline count would include applications that were never sent"),
|
|
(OUTCOME, "## Step 2b: Follow-Up Branch", "neither final nor `drafted`",
|
|
"it would chase an employer who received nothing"),
|
|
(OUTCOME, "## Step 4: Update the Tracker",
|
|
"overwrite its `date` column with the actual submission date",
|
|
"the drafting date would be reported as the application date"),
|
|
(GMAIL_SYNC, None, "`drafted` rows stay in this set",
|
|
"excluding them discards the row that identifies a submitted-but-"
|
|
"unrecorded application, which is the recovery #269 asks for"),
|
|
(GMAIL_SYNC, "## Step 5", "`drafted` -> `applied`, otherwise",
|
|
"the acknowledgement is the one email that proves a hand-submitted "
|
|
"application was sent; classified as noise, the recovery never fires"),
|
|
(GMAIL_SYNC, "### Step 7a", "also set `date` to the email's date",
|
|
"the row would keep the drafting date after being proved submitted"),
|
|
(GMAIL_SYNC, "## Step 9: Staleness Check", "Skip `drafted` rows here",
|
|
"an unsent draft reported as a forgotten application"),
|
|
(NOTION_SYNC, None, "omit when the status is `drafted`",
|
|
"an 'Applied on' date for a job never applied to"),
|
|
(NOTION_SYNC, None, "not yet submitted",
|
|
"page bodies are write-once, so calling drafts 'submitted documents' "
|
|
"is permanent even after /outcome records the real submission"),
|
|
(SCRAPER, None, "do not add a second row",
|
|
"/scrape would duplicate the row Step 3b just wrote"),
|
|
(APPLY, "### Step 6b: Record the Application", "bare number, 0-100",
|
|
"/upskill divides by fit_rating, so `72/100` or a verdict word breaks it"),
|
|
(APPLY, "### Step 6b: Record the Application", "append a new row",
|
|
"re-applying after a rejection would overwrite the old application"),
|
|
]
|
|
|
|
def test_every_reader_handles_drafted(self):
|
|
for path, heading, needle, why in self.CASES:
|
|
with self.subTest(file=path.name, rule=needle):
|
|
haystack = section(path, heading) if heading else path.read_text(encoding="utf-8")
|
|
self.assertIn(needle, haystack, why)
|
|
|
|
@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()
|