mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(html-report): read and render the tracker deadline column (#325)
/html-report was the one tracker consumer #319's deadline column left behind: Step 1 now parses every canonical column and Step 3 renders Deadline after Date. The drift guard derives CANONICAL_HEADER from apply.md itself, so a future column added elsewhere but missing here fails with the column named; legacy 13-field rows read as empty deadline, never dropped, never inferred. Includes rule-6 sweep refinements and deadline-reconciliation rules authored by jakob1379. Co-authored-by: Jakob Stender Guldberg <17257805+jakob1379@users.noreply.github.com>
This commit is contained in:
co-authored by
Jakob Stender Guldberg
parent
c855e11d22
commit
762d3218ef
@@ -109,6 +109,31 @@ class ApplyRecordsApplication(unittest.TestCase):
|
||||
"existing row's values by one position",
|
||||
)
|
||||
|
||||
def test_migration_appends_the_headers_own_last_column(self):
|
||||
"""The migration sentence and the create path must name the same column.
|
||||
|
||||
Derived, never copied - the same discipline `HtmlReportTrackerFieldTests`
|
||||
already applies to its `CANONICAL_HEADER`. A hardcoded `,deadline` here
|
||||
keeps passing after the column is renamed or a fifteenth is appended,
|
||||
because the assertion no longer has any connection to the header it is
|
||||
supposed to police. A tracker migrated by these commands and one they
|
||||
create from scratch would then hold different schemas, which is the exact
|
||||
divergence the shared-header rule exists to prevent.
|
||||
"""
|
||||
last_column = TRACKER_HEADER.rsplit(",", 1)[1]
|
||||
outcome_step_1 = section(OUTCOME, "## Step 1: Load State and Identify the Application")
|
||||
for name, text in (
|
||||
("apply.md Step 6b", section(APPLY, "### Step 6b: Record the Application")),
|
||||
("outcome.md Step 1", outcome_step_1),
|
||||
):
|
||||
self.assertIn(
|
||||
f"append `,{last_column}` to the header line",
|
||||
text,
|
||||
f"{name}'s migration does not append the header's own last column "
|
||||
f"({last_column!r}) - a tracker migrated by this command would not "
|
||||
"match one this command creates from scratch",
|
||||
)
|
||||
|
||||
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
|
||||
"""The optional application-form offer asks the user a question.
|
||||
|
||||
@@ -324,6 +349,31 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
(SKILL, "### Step 3b: Record the Application", "`deadline` is the application deadline",
|
||||
"the /scrape path reaches Step 3b without running /apply Step 0, so it must "
|
||||
"still be told what the field is and where it comes from"),
|
||||
# The two properties the migration has to hold. Both are stated in the
|
||||
# prose of either file and neither was pinned, so either could be edited
|
||||
# away with a green suite - turning an agreed header-line append into a
|
||||
# row rewrite, which is a different and far riskier change.
|
||||
(APPLY, "### Step 6b: Record the Application", "no data row is touched",
|
||||
"a migration that rewrites rows is a different and far riskier change than "
|
||||
"one that appends to the header line, and only the second was agreed"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application", "no data row is touched",
|
||||
"same rule, stated in both files, because either command may be the one that "
|
||||
"meets a legacy tracker first"),
|
||||
(APPLY, "### Step 6b: Record the Application", "read as an empty deadline",
|
||||
"rows written before the migration have no fourteenth field; if that is not "
|
||||
"stated, a reader may treat the short row as malformed and drop it"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application",
|
||||
"read as an empty deadline",
|
||||
"same rule, stated in both files"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application",
|
||||
"one edit to an existing tracker",
|
||||
"Step 4 forbids restructuring the CSV, so without this the header append reads "
|
||||
"as a violation of the same command's own rule and an implementer has a "
|
||||
"documented reason to skip the migration"),
|
||||
(NOTION_SYNC, None, "never reconcile the two by picking the earlier or later date",
|
||||
"the tracker-wins rule says which source to prefer but does not forbid the "
|
||||
"plausible-looking min() of the two, which syncs a date the user never "
|
||||
"applied against"),
|
||||
]
|
||||
|
||||
def test_deadline_survives_every_write(self):
|
||||
|
||||
@@ -5,6 +5,7 @@ properties of the real repo, testing the things CI would catch if the
|
||||
command file or gitignore rule were wrong.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
@@ -42,6 +43,54 @@ class HtmlReportCommandFileTests(unittest.TestCase):
|
||||
self.assertGreater(len(text), 100, "Command file appears suspiciously short")
|
||||
|
||||
|
||||
class HtmlReportTrackerFieldTests(unittest.TestCase):
|
||||
"""The dashboard is a consumer of every tracker column: the Step 1 field
|
||||
enumeration and the Step 3 table columns must stay in phase with the
|
||||
canonical 14-column header (apply.md /outcome.md Step 1.1), so a future
|
||||
column addition cannot silently vanish from the dashboard the way
|
||||
`deadline` did."""
|
||||
|
||||
# Derived, never copied: a header literal repeated in this file drifts in
|
||||
# lockstep with the spec it polices - add a 15th column to apply.md and a
|
||||
# stale hardcoded 14-column list still passes every comparison here (a
|
||||
# 14-column string is a substring of a 15-column header). Reading the
|
||||
# canonical line back from apply.md makes the simulated drift fail with a
|
||||
# clean list diff naming the missing column instead.
|
||||
CANONICAL_HEADER = re.search(
|
||||
r"^\s*(date,company,[a-z_,]+)$",
|
||||
(REPO_ROOT / ".claude" / "commands" / "apply.md").read_text(encoding="utf-8"),
|
||||
re.M,
|
||||
).group(1).split(",")
|
||||
|
||||
def test_step1_parses_every_canonical_tracker_column(self):
|
||||
text = COMMAND_FILE.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"Parse every row into a record with fields:\n\s+((?:`[^`]+`,?\s*)+)",
|
||||
text,
|
||||
)
|
||||
self.assertIsNotNone(match, "Step 1 field enumeration not found")
|
||||
fields = [f.strip() for f in re.findall(r"`([^`]+)`", match.group(1))]
|
||||
self.assertEqual(fields, self.CANONICAL_HEADER)
|
||||
|
||||
def test_step3_table_columns_include_deadline_after_date(self):
|
||||
"""Date · Deadline order is the whole point of the change: the dashboard
|
||||
must surface the clock that drives `/rank`'s urgency next to the date.
|
||||
A membership pair (both `Date` and `Deadline` present somewhere) cannot
|
||||
tell a swapped order from the correct one, and the order is what the
|
||||
table shows the reader."""
|
||||
text = COMMAND_FILE.read_text(encoding="utf-8")
|
||||
match = re.search(r"### Table: columns to include\n\n(.+)\n", text)
|
||||
self.assertIsNotNone(match, "Step 3 table column list not found")
|
||||
line = match.group(1)
|
||||
self.assertIn(
|
||||
"`Date` · `Deadline` · `Company`",
|
||||
line,
|
||||
"Step 3 must offer the Deadline column directly after Date - the "
|
||||
"list defines the dashboard's column order, and a swapped order "
|
||||
"reads as a different table",
|
||||
)
|
||||
|
||||
|
||||
class HtmlReportGitignoreTests(unittest.TestCase):
|
||||
"""reports/ must be gitignored — it holds personal generated output."""
|
||||
|
||||
|
||||
@@ -213,6 +213,76 @@ class RankCommandSpec(unittest.TestCase):
|
||||
"Step 5's template must name the Closing soon heading rule 6 lists under",
|
||||
)
|
||||
|
||||
def test_step3_sweep_states_its_two_boundary_rules(self):
|
||||
"""The sweep's behaviour on the majority case, and its reversibility.
|
||||
|
||||
Most `seen_jobs.json` entries predate the deadline column and carry no
|
||||
`deadline` at all, so "left alone" versus "inferred from first_seen" is
|
||||
the difference between a no-op and retiring jobs on a date nobody set.
|
||||
And a status change made without a fetch needs a stated way back, or
|
||||
`expired` reads as terminal and a wrongly swept job looks unrecoverable.
|
||||
"""
|
||||
step3 = _sections(COMMAND.read_text(encoding="utf-8")).get("Step 3: Aggregate and Rank", "")
|
||||
self.assertIn(
|
||||
"never guessed at",
|
||||
step3,
|
||||
"rule 6 must say an entry with no stored deadline is left alone - it is the "
|
||||
"majority case, and inferring one would retire jobs on a date nobody set",
|
||||
)
|
||||
self.assertIn(
|
||||
"revived by a later `--all`",
|
||||
step3,
|
||||
"rule 6 must state that --all re-scores expired entries, or the sweep is an "
|
||||
"irreversible automated status change",
|
||||
)
|
||||
|
||||
def test_step4_sweep_is_named_as_the_exception_to_idempotency(self):
|
||||
"""Rule 6 mutates exactly the entries Step 4 says are skipped.
|
||||
|
||||
Step 4's closing line predates the sweep and says already-`ranked` jobs
|
||||
are skipped unless `--all` re-scores them. Rule 6 rewrites some of those
|
||||
same entries to `expired` with no `--all` and no re-score, so the two
|
||||
sections contradict each other unless the exception is named. An
|
||||
implementer following Step 4 literally skips the sweep, which is the
|
||||
whole feature.
|
||||
"""
|
||||
step4 = _sections(COMMAND.read_text(encoding="utf-8")).get("Step 4: Update State", "")
|
||||
self.assertIn(
|
||||
"deliberate exception",
|
||||
step4,
|
||||
"Step 4's idempotency line must name rule 6's sweep as its exception, or the "
|
||||
"spec tells the reader both that already-ranked entries are skipped and that "
|
||||
"they are swept",
|
||||
)
|
||||
|
||||
def test_step4_null_deadline_rule_states_its_interlock_with_the_sweep(self):
|
||||
"""Absence-is-not-a-correction is load-bearing, not politeness.
|
||||
|
||||
A `null` from a fetch that degraded to a listing page would erase a real
|
||||
stored date; because rule 6 leaves an entry with no stored deadline
|
||||
alone, that erasure also makes the entry permanently unsweepable. The
|
||||
two rules interlock, and an unexplained constraint is the kind that gets
|
||||
simplified away later.
|
||||
"""
|
||||
step4 = _sections(COMMAND.read_text(encoding="utf-8")).get("Step 4: Update State", "")
|
||||
self.assertIn(
|
||||
"immortal to the sweep",
|
||||
step4,
|
||||
"the null-overwrite rule must state why it matters here: erasing a stored "
|
||||
"deadline also removes the entry from rule 6's reach forever",
|
||||
)
|
||||
|
||||
def test_step5_reports_the_sweep_counts(self):
|
||||
"""A background status mutation with no reported count is the failure mode
|
||||
this whole change set exists to object to."""
|
||||
step5 = _sections(COMMAND.read_text(encoding="utf-8")).get("Job Ranking - YYYY-MM-DD", "")
|
||||
self.assertIn(
|
||||
"Swept",
|
||||
step5,
|
||||
"Step 5's template must report how many already-ranked entries the sweep "
|
||||
"checked and how many it retired - it rewrites seen_jobs.json silently otherwise",
|
||||
)
|
||||
|
||||
def test_step4_persists_the_sweeps_expiry(self):
|
||||
"""The sweep must write its result, or it reproduces the very bug it fixes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user