mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(workflow): define tracker status enum once in /outcome, normalise readers (#299)
* fix(workflow): define tracker status enum once in /outcome, normalise readers (#298) The tracker CSV status column had no single authoritative definition. Six command files restated it with inconsistent spellings, producing two concrete bugs: - /outcome Step 4 wrote o response and offer declined (spaces). /html-report normalised only the underscore forms, so those rows matched no bucket and were silently dropped from the rejection-rate denominator. - /gmail-sync Step 2 hardcoded the final-status set with space spellings, so a row written with underscores was never recognised as final and the sync kept chasing closed applications. - /html-report included interview_only in its tracker bucket map; that value belongs to the archive outcome.md Status: field, not the CSV status column. Fix: add a '## Tracker status vocabulary' block in /outcome (the only CSV writer) defining the canonical underscore spellings once. Every reader now references that block or explicitly lists both spelling forms as read-tolerance for existing trackers. /outcome Step 4 writes no_response and offer_declined. /html-report loses interview_only and gains offer declined as a read-tolerance variant. /notion-sync Step 3 Status select options are aligned to the canonical spellings. Pinned by tests/test_tracker_status_vocab.py (9 new cases following the DraftedMeansDraftedToEveryReader CASES-table pattern). All 205 tests pass. framework_version: 1.3.0 -> 1.3.1 * fix(workflow): address review findings on the tracker status enum (#298) Follow-up to ca40df2, incorporating the maintainer and issue-author reviews. Blockers fixed: - CHANGELOG: the #298 entry had replaced the opening line of the #286 robots entry, leaving its body dangling under the new fork heads-up. Restored the deleted line and made the #298 entry self-contained above it (MadsLorentzen). - /notion-sync Step 4 now normalises legacy space spellings to the canonical underscore forms before setting the Status property. A raw push would auto-create a separate Notion select option per unique string, splitting closed applications across two filter buckets in an existing database (MadsLorentzen). Issue-author findings: - The vocabulary block now states that the space spellings are the same values as the underscore forms, not separate statuses, equally Final. Previously a reader applying the Open/Final lists literally landed on "not Final, not Open, undefined" for `offer declined`, and /apply Step 6b would refresh a closed application's row instead of appending (jakob1379). - The block moved below Step 1's closing --- as its own section: it was splitting Step 1's numbered list and silently truncating section-scoped reads of Step 1 to item 1 (jakob1379). - Open is derived by exclusion from the one explicit Final list, so a new status needs updating in a single place (jakob1379). - /html-report's bucket map gains a case-insensitive catch-all that maps unrecognised values to Rejected/Closed and names them once in the status breakdown - the #298 failure mode with a different input (jakob1379). - /apply Step 6b and /interview Step 0 anchor their final/open decisions to the vocabulary block (jakob1379). - /gmail-sync and /html-report drop their local restatements of the read-tolerance rule (jakob1379). Tests: html-report bucket assertions scoped to the Step 1 section; new pins for the equivalence clause, open-by-exclusion, block placement, the Notion normalisation, and the apply/interview anchors.
This commit is contained in:
@@ -319,7 +319,7 @@ Do this before the optional offer below, and before ending the turn for any othe
|
||||
```
|
||||
date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source
|
||||
```
|
||||
2. Match existing rows case-insensitively on company and role. **On no match, or when every match holds a final status, append a new row. On a match that is still open, update it.** When you append alongside a final row, say so — the earlier application to that role keeps its own row and its own outcome.
|
||||
2. Match existing rows case-insensitively on company and role. **On no match, or when every match holds a final status, append a new row. On a match that is still open, update it.** "Final" and "open" are defined by the **Tracker status vocabulary** in `/outcome` — the legacy space spellings `no response` / `offer declined` count as final, so a closed application never gets its row overwritten. When you append alongside a final row, say so — the earlier application to that role keeps its own row and its own outcome.
|
||||
3. Values for a new row:
|
||||
|
||||
| Column | Value |
|
||||
|
||||
@@ -28,7 +28,7 @@ Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, t
|
||||
|
||||
1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones.
|
||||
2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`).
|
||||
3. Build the set of **open applications**: tracker rows whose `status` is not a final value (`hired`, `rejected`, `no response`, `offer declined`, `withdrawn`). For each, derive its archive folder `documents/applications/<company>_<role>/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there.
|
||||
3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/<company>_<role>/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there.
|
||||
|
||||
**`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs.
|
||||
4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess.
|
||||
|
||||
@@ -27,7 +27,12 @@ Status normalisation — map tracker values to six canonical buckets before comp
|
||||
- `interview` → **Interview**
|
||||
- `offer` → **Offer**
|
||||
- `hired` → **Hired**
|
||||
- `rejected` / `no_response` / `no response` / `offer_declined` / `interview_only` / `withdrawn` → **Rejected/Closed**
|
||||
- `rejected` / `no_response` / `no response` / `offer_declined` / `offer declined` / `withdrawn` → **Rejected/Closed**
|
||||
- anything else → **Rejected/Closed**, and name the unrecognised value once in the status breakdown — matching is case-insensitive
|
||||
|
||||
The bucket map tolerates the legacy space spellings on read so nothing written before
|
||||
the canonical forms were locked drops out of the stats; the **Tracker status vocabulary**
|
||||
in `/outcome` is the authoritative set.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Follow these steps **in order**.
|
||||
`$ARGUMENTS` may contain a company name (optionally with a role), e.g. `/interview acme`.
|
||||
|
||||
- **With an argument:** match against `job_search_tracker.csv` rows (case-insensitive on company, then role). One match → proceed. Several → list and ask. None → this application isn't tracked; suggest `/outcome <company>` to register it first, or accept the posting and role details directly if the user wants to prep anyway.
|
||||
- **Without an argument:** list tracker rows whose status suggests a live process (`interview`, `offer`, or recently `applied`) and ask which one. If the tracker is empty, ask for the company, role, and posting.
|
||||
- **Without an argument:** list tracker rows whose status suggests a live process — an open status per the **Tracker status vocabulary** in `/outcome` (`interview`, `offer`, or recently `applied`; `drafted` is open but nothing was sent, so it never qualifies) — and ask which one. If the tracker is empty, ask for the company, role, and posting.
|
||||
|
||||
v1 preps for a **specific application**. Generic no-target practice is out of scope - if asked, prep against a real tracked application instead.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ Validate the cheap, local precondition before creating anything external. A run
|
||||
| Company | rich text | |
|
||||
| Score | number | 0-100 from `rank_score` |
|
||||
| Verdict | select | Strong Fit / Good Fit / Moderate Fit / Weak Fit / Poor Fit |
|
||||
| Status | select | ranked / drafted / applied / interview / offer / hired / rejected / no response / withdrawn / expired |
|
||||
| Status | select | `ranked` / `drafted` / `applied` / `interview` / `offer` / `hired` / `rejected` / `no_response` / `offer_declined` / `withdrawn` / `expired` — canonical tracker spellings per **Tracker status vocabulary** in `/outcome`; Notion options grow to match as values appear |
|
||||
| Fit | select | high / medium / low (scraper quick-fit) |
|
||||
| Deadline | date | omit when unknown |
|
||||
| First seen | date | |
|
||||
@@ -90,6 +90,8 @@ For each job in the sync set:
|
||||
3. **Match** → update **properties only**: Status, Score, Verdict, Deadline, Ranked, Applied on, Channel, CV file, Cover letter. Properties are the always-current surface (bodies are write-once), so tracker updates recorded by `/outcome` reach the destination exclusively through them. Do not touch the page body - the user may have added their own notes there, and clobbering them breaks trust in the whole view. (`--rebuild` is the sole exception.)
|
||||
4. Never delete or archive pages, even for jobs that turned `expired` - set Status to `expired` instead. Rows the user added to the database by hand (no `Key` value) are invisible to this command.
|
||||
|
||||
**Normalise the Status value before writing.** The tracker may hold legacy space spellings (`no response`, `offer declined`) from before the canonical forms were locked. Map them to `no_response` / `offer_declined` per the **Tracker status vocabulary** in `/outcome` before setting Status on create or update - never push a space form to Notion, which would auto-create a separate select option per unique string. Pre-existing space-form options in an existing database simply go unused; Notion never auto-removes select options.
|
||||
|
||||
Batch politely: if the MCP server rate-limits, back off and continue; report any page that failed rather than retrying indefinitely.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,13 +32,31 @@ Follow these steps **in order**.
|
||||
date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source
|
||||
```
|
||||
2. **With an argument:** match rows case-insensitively on company (and role, if given). One match → proceed. Several → list them and ask. None → the application was made outside the workflow; collect company, role, date applied, channel, and posting URL from the user and add a tracker row.
|
||||
3. **Without an argument:** list all rows whose status is not final (not hired / rejected / no response / withdrawn / offer declined) as a numbered table (company, role, date applied, current status, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: **days quiet** counts from the row's `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop.
|
||||
3. **Without an argument:** list all rows whose status is not final (see **Tracker status vocabulary** below) as a numbered table (company, role, date applied, current status, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: **days quiet** counts from the row's `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop.
|
||||
|
||||
**`drafted` rows are listed but never counted as quiet** - nothing was sent, so nobody is late replying. List them under their own heading ("Drafted, not yet submitted"), leave **days quiet** and **follow-ups sent** blank, and keep them out of the follow-up offer above.
|
||||
4. Derive the archive folder name: `documents/applications/<company>_<role>/` - lowercase, underscores for spaces (the convention documented in `documents/README.md`). Check whether the folder and an `outcome.md` already exist - if so, you are updating, not creating.
|
||||
|
||||
---
|
||||
|
||||
## Tracker status vocabulary
|
||||
|
||||
Canonical spellings for the tracker CSV `status` column (underscores, never spaces):
|
||||
|
||||
`drafted` | `applied` | `interview` | `offer` | `hired` | `rejected` | `no_response` | `offer_declined` | `withdrawn`
|
||||
|
||||
- **Final** (application closed): `hired`, `rejected`, `no_response`, `offer_declined`, `withdrawn`
|
||||
- **Open**: everything else, `drafted` included — a row is active until its status is one of the **Final** values.
|
||||
- **`drafted`** is open but distinct — nothing was sent, so no follow-up is ever due.
|
||||
- Readers must also accept the legacy space spellings `no response` and `offer declined` on read, so that existing trackers keep working without a migration. Never write them — they are the same values as `no_response` and `offer_declined`, not separate statuses, equally **Final**, and every rule that names one applies to the other.
|
||||
|
||||
> Distinct from the archive `Status:` enum in `documents/README.md`
|
||||
> (`in_progress` | `hired` | `offer_declined` | `rejected` | `no_response` | `interview_only`),
|
||||
> which describes the per-application `outcome.md` file, not this column. The two enums
|
||||
> are never written to the same field.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Collect What Happened
|
||||
|
||||
Ask the user what happened, then classify:
|
||||
@@ -47,7 +65,7 @@ Ask the user what happened, then classify:
|
||||
- Interview invitation / stage scheduled or completed (phone screen, technical, case, final round)
|
||||
- Offer received (not yet accepted or declined)
|
||||
|
||||
**Resolutions** (application closed) - these map to the status enum in `documents/README.md` that `/setup` parses:
|
||||
**Resolutions** (application closed) — these map to the archive `Status:` enum in `documents/README.md` that `/setup` parses (distinct from the tracker CSV column; see **Tracker status vocabulary** above):
|
||||
- `hired` - accepted an offer
|
||||
- `offer_declined` - received an offer, turned it down
|
||||
- `rejected` - explicit rejection at any stage
|
||||
@@ -123,7 +141,7 @@ Update rules: tick stage checkboxes as they are reached (add the date in parenth
|
||||
|
||||
## Step 4: Update the Tracker
|
||||
|
||||
Update the matched row's `status` column (e.g. `drafted` → `applied` → `interview` → `offer` → `hired` / `rejected` / `no response` / `offer declined` / `withdrawn`) and append a short dated note to the `notes` column. Never restructure the CSV, reorder rows, or touch other rows.
|
||||
Update the matched row's `status` column using the canonical spellings from **Tracker status vocabulary** above (e.g. `drafted` → `applied` → `interview` → `offer` → `hired` / `rejected` / `no_response` / `offer_declined` / `withdrawn`) and append a short dated note to the `notes` column. Never restructure the CSV, reorder rows, or touch other rows.
|
||||
|
||||
**Moving a row off `drafted`:** rows written by `/apply` Step 6b carry the date the documents were drafted, not the date they were sent. Whenever this step advances such a row to any other status - `applied`, or straight to `interview` or `rejected` when the user reports an outcome for something they submitted without recording it - overwrite its `date` column with the actual submission date. The `date` column is read as "applied on" by `/notion-sync` and drives `/html-report`'s year/season grouping and this command's own days-quiet count, so leaving the draft date in place would misreport the application.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >
|
||||
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
||||
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
||||
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion
|
||||
framework_version: 1.3.0
|
||||
framework_version: 1.3.1
|
||||
---
|
||||
|
||||
# Job Application Assistant
|
||||
|
||||
@@ -13,6 +13,40 @@ per-file diff commands.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tracker status enum defined once; `offer declined`/`no response` now reach the correct
|
||||
`/html-report` bucket and `/gmail-sync` correctly marks them final** (#298). The tracker
|
||||
CSV `status` column had no single authoritative definition. Six command files restated it
|
||||
independently with inconsistent spellings, producing two concrete bugs:
|
||||
|
||||
- `/outcome` Step 4 wrote `no response` and `offer declined` (with spaces). `/html-report`
|
||||
Step 1 normalised only `no_response` / `offer_declined` (underscores), so any row written
|
||||
with spaces matched no bucket and was silently dropped from the rejection-rate denominator.
|
||||
- `/gmail-sync` Step 2 defined the "final" set with the space forms, so a row written with
|
||||
underscores was never recognised as final and the sync kept chasing closed applications.
|
||||
- `/html-report` included `interview_only` in the tracker bucket map; that value belongs to
|
||||
the archive `outcome.md` `Status:` field, not the CSV `status` column.
|
||||
|
||||
Fix: a `## Tracker status vocabulary` block in `/outcome` (the only writer of the CSV)
|
||||
now defines the canonical set once with underscore spellings and the **Final** set by
|
||||
explicit list — everything else, `drafted` included, is **Open**. The legacy space
|
||||
spellings are the same values, not separate statuses: equally **Final**, and every rule
|
||||
that names one form applies to the other — readers must accept them on read, and never
|
||||
write them. Every reader that makes final/open decisions references that block (`/apply`
|
||||
Step 6b, `/interview` Step 0, `/gmail-sync` Step 2, `/html-report` Step 1, `/notion-sync`
|
||||
Steps 3-4). `/outcome` Step 4 writes `no_response` / `offer_declined`; `/notion-sync`
|
||||
normalises both forms to the canonical spellings before setting the Status property;
|
||||
`/html-report`'s bucket map loses `interview_only`, keeps both spellings, and gains a
|
||||
case-insensitive catch-all that maps unrecognised values to **Rejected/Closed** and names
|
||||
them once in the status breakdown. Pinned by `tests/test_tracker_status_vocab.py`.
|
||||
|
||||
**Fork heads-up:** if your personalized `/outcome` adds `no response` or `offer declined`
|
||||
(space forms) to the tracker write path, swap them for the underscore forms. Existing rows
|
||||
keep working because every reader now accepts both spellings on read. If your Notion
|
||||
database already carries space-form Status options, they simply go unused — Notion never
|
||||
auto-removes select options.
|
||||
|
||||
## [1.4.0] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Guards for the tracker status vocabulary (issue #298).
|
||||
|
||||
The tracker CSV `status` column has a single authoritative definition in
|
||||
/outcome's "Tracker status vocabulary" block. Every reader that mentions
|
||||
final or open statuses must defer to that block or explicitly accept both
|
||||
the canonical underscore spellings and the legacy space spellings on read.
|
||||
|
||||
These tests pin the two concrete bugs that opened #298:
|
||||
|
||||
1. `offer declined` (space form, written by the old /outcome Step 4) landed
|
||||
in no /html-report bucket, silently shrinking the rejection-rate denominator.
|
||||
2. `interview_only` was listed as a tracker bucket value in /html-report, but
|
||||
it belongs to the archive outcome.md Status: enum, never the CSV column.
|
||||
|
||||
They also pin the review findings on the fix itself:
|
||||
|
||||
3. The space spellings are the same statuses as the underscore forms (equally
|
||||
Final), so a reader applying the lists literally cannot land on "not Final,
|
||||
not Open, undefined" - which would otherwise misroute a closed application
|
||||
in /apply's append-vs-update decision.
|
||||
4. The vocabulary block must not split Step 1's numbered list: section-scoped
|
||||
reads of Step 1 must still see items 2-4.
|
||||
5. /html-report's bucket map needs a catch-all so no tracker value drops out of
|
||||
the stats silently, and /notion-sync must normalise space forms before
|
||||
writing Status (Notion auto-creates a select option per unique string).
|
||||
6. /apply and /interview make status decisions and must anchor them to the
|
||||
block, not restate an ad-hoc set.
|
||||
|
||||
They follow the CASES-table pattern from test_apply_records_application.py so
|
||||
that adding a new reader is a one-line addition to READER_CASES.
|
||||
"""
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
COMMANDS = REPO / ".claude" / "commands"
|
||||
|
||||
OUTCOME = COMMANDS / "outcome.md"
|
||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
||||
HTML_REPORT = COMMANDS / "html-report.md"
|
||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
||||
APPLY = COMMANDS / "apply.md"
|
||||
INTERVIEW = COMMANDS / "interview.md"
|
||||
|
||||
VOCAB_ANCHOR = "## Tracker status vocabulary"
|
||||
|
||||
|
||||
def section(path: Path, heading: str) -> str:
|
||||
"""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 VocabularyBlockExists(unittest.TestCase):
|
||||
"""The canonical definition must live in /outcome and nowhere else."""
|
||||
|
||||
def test_outcome_has_vocabulary_block(self):
|
||||
self.assertIn(
|
||||
VOCAB_ANCHOR,
|
||||
OUTCOME.read_text(encoding="utf-8"),
|
||||
"/outcome must contain the ## Tracker status vocabulary block — "
|
||||
"that block is the single source of truth for tracker CSV spellings",
|
||||
)
|
||||
|
||||
def test_vocabulary_block_lists_underscore_canonical_spellings(self):
|
||||
vocab = section(OUTCOME, VOCAB_ANCHOR)
|
||||
for canonical in ("no_response", "offer_declined"):
|
||||
self.assertIn(
|
||||
f"`{canonical}`",
|
||||
vocab,
|
||||
f"The vocabulary block must list `{canonical}` as a canonical spelling",
|
||||
)
|
||||
|
||||
def test_vocabulary_block_has_read_tolerance_line(self):
|
||||
vocab = section(OUTCOME, VOCAB_ANCHOR)
|
||||
self.assertIn(
|
||||
"no response",
|
||||
vocab,
|
||||
"The vocabulary block must mention the legacy space spelling 'no response' "
|
||||
"so readers know to accept it on read",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer declined",
|
||||
vocab,
|
||||
"The vocabulary block must mention the legacy space spelling 'offer declined' "
|
||||
"so readers know to accept it on read",
|
||||
)
|
||||
|
||||
def test_vocabulary_block_states_equivalence_of_space_forms(self):
|
||||
"""The space spellings are the same statuses as the underscore forms, not
|
||||
separate values. Without this, a reader applying the Open/Final lists
|
||||
literally lands on "not Final, not Open, undefined" for `offer declined`,
|
||||
and /apply Step 6b would take the update branch for a closed application
|
||||
instead of appending a fresh row - losing the earlier document trail."""
|
||||
vocab = section(OUTCOME, VOCAB_ANCHOR)
|
||||
self.assertIn(
|
||||
"same values",
|
||||
vocab,
|
||||
"The vocabulary block must state that the space spellings are the same "
|
||||
"values as the canonical underscore forms",
|
||||
)
|
||||
self.assertIn(
|
||||
"not separate statuses",
|
||||
vocab,
|
||||
"The vocabulary block must state that the space spellings are not "
|
||||
"separate statuses",
|
||||
)
|
||||
self.assertIn(
|
||||
"equally",
|
||||
vocab,
|
||||
"The vocabulary block must state that the space spellings are equally "
|
||||
"Final, so finality decisions cover them",
|
||||
)
|
||||
|
||||
def test_vocabulary_block_defines_open_by_exclusion(self):
|
||||
"""Open is derived by exclusion from the one explicit Final list, so a new
|
||||
status needs updating in a single place and unknown values stay open until
|
||||
declared final."""
|
||||
vocab = section(OUTCOME, VOCAB_ANCHOR)
|
||||
self.assertIn(
|
||||
"everything else",
|
||||
vocab,
|
||||
"The vocabulary block must define Open as everything not in the Final "
|
||||
"list, not as a second explicit list that can drift",
|
||||
)
|
||||
|
||||
def test_step1_section_contains_all_items(self):
|
||||
"""The vocabulary block must live as its own section below Step 1's closing
|
||||
---, not between Step 1's numbered items. A block inside the list truncates
|
||||
section-scoped reads of Step 1 to item 1, and a future test scoped to Step 1
|
||||
would pass against a stub."""
|
||||
step1 = section(OUTCOME, "## Step 1: Load State and Identify the Application")
|
||||
for needle in ("With an argument", "Without an argument", "Derive the archive"):
|
||||
self.assertIn(
|
||||
needle,
|
||||
step1,
|
||||
f"Step 1's numbered list must be intact - '{needle}' must sit inside "
|
||||
"Step 1, not under the vocabulary heading",
|
||||
)
|
||||
|
||||
def test_outcome_step4_writes_underscore_forms(self):
|
||||
"""The writer must use canonical underscore spellings, never space forms."""
|
||||
step4 = section(OUTCOME, "## Step 4: Update the Tracker")
|
||||
# The canonical forms must be present as the write target
|
||||
self.assertIn(
|
||||
"no_response",
|
||||
step4,
|
||||
"Step 4 must write `no_response` (underscore), not `no response` (space)",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer_declined",
|
||||
step4,
|
||||
"Step 4 must write `offer_declined` (underscore), not `offer declined` (space)",
|
||||
)
|
||||
|
||||
|
||||
class ReadersBucketMap(unittest.TestCase):
|
||||
"""Each reader that classifies tracker values must handle both spellings
|
||||
and must not include archive-only values in tracker buckets."""
|
||||
|
||||
def test_html_report_bucket_includes_space_and_underscore_forms(self):
|
||||
"""Read-tolerance: both spellings must reach the Rejected/Closed bucket."""
|
||||
# Scope to the bucket-map section, not the whole file, so the assertion
|
||||
# proves the mapping exists where stats are computed - a stray mention
|
||||
# anywhere else in the file would otherwise satisfy it.
|
||||
step1 = section(HTML_REPORT, "## Step 1: Collect Data")
|
||||
self.assertIn(
|
||||
"no response",
|
||||
step1,
|
||||
"/html-report must accept the legacy 'no response' (space) form so that "
|
||||
"existing trackers are not silently excluded from stats",
|
||||
)
|
||||
self.assertIn(
|
||||
"no_response",
|
||||
step1,
|
||||
"/html-report must accept the canonical 'no_response' (underscore) form",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer declined",
|
||||
step1,
|
||||
"/html-report must accept the legacy 'offer declined' (space) form",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer_declined",
|
||||
step1,
|
||||
"/html-report must accept the canonical 'offer_declined' (underscore) form",
|
||||
)
|
||||
|
||||
def test_html_report_bucket_map_has_catch_all(self):
|
||||
"""No tracker value may drop out of the stats silently: unrecognised values
|
||||
fall to Rejected/Closed and are named once in the status breakdown."""
|
||||
step1 = section(HTML_REPORT, "## Step 1: Collect Data")
|
||||
self.assertIn(
|
||||
"anything else",
|
||||
step1,
|
||||
"The bucket map must have a catch-all line for unrecognised tracker values",
|
||||
)
|
||||
self.assertIn(
|
||||
"unrecognised",
|
||||
step1,
|
||||
"The catch-all must name the unrecognised value once so the drop is "
|
||||
"visible instead of silent",
|
||||
)
|
||||
|
||||
def test_html_report_bucket_does_not_contain_interview_only(self):
|
||||
"""`interview_only` is the archive outcome.md Status: enum value,
|
||||
never a tracker CSV status. Listing it in the tracker bucket map
|
||||
confuses the two enums and would classify archive-only values
|
||||
that should not appear in the CSV."""
|
||||
# We only care about the bucket map section, not the whole file,
|
||||
# to avoid false positives from comments or this test file itself.
|
||||
step1 = section(HTML_REPORT, "## Step 1: Collect Data")
|
||||
self.assertNotIn(
|
||||
"interview_only",
|
||||
step1,
|
||||
"`interview_only` must not appear in /html-report's tracker bucket map — "
|
||||
"it is part of the archive `outcome.md` Status: enum, not a tracker CSV value",
|
||||
)
|
||||
|
||||
def test_gmail_sync_references_vocabulary_block(self):
|
||||
"""gmail-sync must defer to /outcome's vocabulary block for the
|
||||
open-application set, not hardcode the final-status set with
|
||||
space spellings that diverge from the writer."""
|
||||
step2_text = section(GMAIL_SYNC, "## Step 2: Load State")
|
||||
self.assertIn(
|
||||
"Tracker status vocabulary",
|
||||
step2_text,
|
||||
"/gmail-sync Step 2 must reference the /outcome vocabulary block "
|
||||
"instead of restating the final-status set with its own spellings",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"no response",
|
||||
step2_text,
|
||||
"/gmail-sync Step 2 must not restate the space spellings locally - "
|
||||
"the vocabulary block is the single source for what counts as final, "
|
||||
"and a second local list is what drifted in #298",
|
||||
)
|
||||
|
||||
def test_notion_sync_normalises_status_before_write(self):
|
||||
"""Step 4 must map legacy space spellings to canonical before setting
|
||||
Status. Notion auto-creates a select option per unique string, so pushing
|
||||
a space form would give an existing database two options for one status
|
||||
and split closed applications across two filter buckets."""
|
||||
step4_text = section(NOTION_SYNC, "## Step 4: Upsert Database Rows")
|
||||
self.assertIn(
|
||||
"never push a space form",
|
||||
step4_text,
|
||||
"/notion-sync Step 4 must never write a space-form status to Notion",
|
||||
)
|
||||
self.assertIn(
|
||||
"Tracker status vocabulary",
|
||||
step4_text,
|
||||
"/notion-sync Step 4 must map space forms per the /outcome vocabulary block",
|
||||
)
|
||||
|
||||
def test_notion_sync_uses_underscore_status_spellings(self):
|
||||
"""Notion Status select options must match canonical tracker spellings
|
||||
so that upserted values are consistent with what /outcome writes."""
|
||||
step3_text = section(NOTION_SYNC, "## Step 3: Load Sync State and Locate the Database")
|
||||
self.assertIn(
|
||||
"no_response",
|
||||
step3_text,
|
||||
"/notion-sync Step 3 must list `no_response` (underscore) as a Status "
|
||||
"option so it matches what /outcome writes to the tracker",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer_declined",
|
||||
step3_text,
|
||||
"/notion-sync Step 3 must list `offer_declined` (underscore) as a Status "
|
||||
"option so it matches what /outcome writes to the tracker",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"no response",
|
||||
step3_text,
|
||||
"/notion-sync Step 3 must not list 'no response' (space) as the primary "
|
||||
"option — Notion creates a distinct select value for each unique string, "
|
||||
"so mixing spellings creates duplicate options in the database",
|
||||
)
|
||||
|
||||
|
||||
class ReaderCases(unittest.TestCase):
|
||||
"""Spot-checks across readers that prove the vocabulary block is reachable
|
||||
from each command that makes decisions based on tracker status.
|
||||
|
||||
Format: (path, heading_or_None, needle, failure_message)
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
# /outcome owns the vocabulary; its readers must find it there
|
||||
(
|
||||
OUTCOME,
|
||||
VOCAB_ANCHOR,
|
||||
"underscores, never spaces",
|
||||
"The vocabulary block must state that underscores are canonical and "
|
||||
"spaces are not to be written",
|
||||
),
|
||||
(
|
||||
OUTCOME,
|
||||
VOCAB_ANCHOR,
|
||||
"Final",
|
||||
"The vocabulary block must define the final-status set explicitly",
|
||||
),
|
||||
# /html-report Step 2 excludes drafted from stats
|
||||
(
|
||||
HTML_REPORT,
|
||||
"## Step 2: Compute Summary Stats",
|
||||
"excluded from every statistic below",
|
||||
"drafted rows must be excluded from every statistic, not counted as sent",
|
||||
),
|
||||
# /gmail-sync staleness check skips drafted
|
||||
(
|
||||
GMAIL_SYNC,
|
||||
"## Step 9: Staleness Check",
|
||||
"Skip `drafted` rows here",
|
||||
"the staleness check must skip drafted rows — nothing was sent, "
|
||||
"so nobody is late replying",
|
||||
),
|
||||
# /apply's append-vs-update decision anchors to the vocabulary
|
||||
(
|
||||
APPLY,
|
||||
"### Step 6b: Record the Application",
|
||||
"Tracker status vocabulary",
|
||||
"apply.md Step 6b must anchor its final-status decision to the /outcome "
|
||||
"vocabulary block — a closed application must never be treated as open "
|
||||
"and get its row updated instead of appended",
|
||||
),
|
||||
# /interview's live-process set anchors to the vocabulary
|
||||
(
|
||||
INTERVIEW,
|
||||
"## Step 0: Parse Input",
|
||||
"Tracker status vocabulary",
|
||||
"interview.md Step 0 must anchor its live-process statuses to the "
|
||||
"/outcome vocabulary block instead of restating an ad-hoc set",
|
||||
),
|
||||
]
|
||||
|
||||
def test_all_reader_cases(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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user