diff --git a/tests/test_generate_cv.py b/tests/test_generate_cv.py new file mode 100644 index 0000000..ec46cca --- /dev/null +++ b/tests/test_generate_cv.py @@ -0,0 +1,184 @@ +"""Contract tests for tools/generate_cv.py. + +The generator turns documents/sources/ YAML plus a role config into a +LaTeX CV. What must hold: + +- Source description blocks split into the right bullets, including the + leading-prose case (projects/ecosystem-devrel.yml). +- Every escape from 05-cv-templates.md's special-character table is + applied; `%` is the silent killer (truncates the rest of the line). +- Date ranges use a single ASCII hyphen, never `--` (en-dash ligature + breaks ATS date parsing - confirmed Workday import failure). +- `use: N` bullet refs resolve 1-based and fail loudly out of range. +- Skill labels absent from documents/sources/ warn unless allowlisted + via `extra-skills`; the shipped role configs generate warning-free. +- Generated documents never contain `\\item [` (F9: clipped label trap). +""" +import datetime +import re +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "tools")) + +import generate_cv # noqa: E402 + +UNBRACED_BRACKET_ITEM = re.compile(r"\\item\s*\[") + + +class TestParseBullets(unittest.TestCase): + def test_splits_bullets_and_joins_continuations(self): + text = "- first bullet\n continues here\n- second bullet\n" + self.assertEqual( + generate_cv.parse_bullets(text), + ["first bullet continues here", "second bullet"], + ) + + def test_leading_prose_becomes_first_bullet(self): + text = "Intro paragraph.\n\n- real bullet\n" + self.assertEqual( + generate_cv.parse_bullets(text), + ["Intro paragraph.", "real bullet"], + ) + + def test_empty_description(self): + self.assertEqual(generate_cv.parse_bullets(None), []) + + +class TestLatexEscape(unittest.TestCase): + def test_special_characters(self): + self.assertEqual( + generate_cv.latex_escape("99% uptime & $4.7M for C# devs_v1"), + "99\\% uptime \\& \\$4.7M for C\\# devs\\_v1", + ) + + def test_unicode_punctuation(self): + self.assertEqual(generate_cv.latex_escape("a — b – c"), "a --- b -- c") + self.assertEqual(generate_cv.latex_escape("\u201cMotr\u201d"), "``Motr''") + self.assertEqual(generate_cv.latex_escape("Yu\u0113"), "Yu\\=e") + self.assertEqual( + generate_cv.latex_escape("A \u00b7 B"), "A \\textperiodcentered{} B" + ) + + +class TestFormatRange(unittest.TestCase): + def test_open_range_uses_ascii_hyphen_and_present(self): + start = datetime.date(2021, 3, 1) + self.assertEqual(generate_cv.format_range(start, None), "Mar 2021 - Present") + + def test_year_only_range(self): + start = datetime.date(2022, 1, 1) + end = datetime.date(2023, 8, 1) + self.assertEqual( + generate_cv.format_range(start, end, month=False), "2022 - 2023" + ) + + def test_never_emits_double_hyphen(self): + start = datetime.date(2016, 7, 1) + end = datetime.date(2019, 5, 1) + self.assertNotIn("--", generate_cv.format_range(start, end)) + + +class TestResolveBullets(unittest.TestCase): + SOURCE = {"description": "- alpha\n- beta\n- gamma\n"} + PATH = Path("fake.yml") + + def test_use_index_is_one_based(self): + entry = {"bullets": [{"use": 2}, "literal text"]} + self.assertEqual( + generate_cv.resolve_bullets(entry, self.SOURCE, self.PATH), + ["beta", "literal text"], + ) + + def test_default_is_all_bullets(self): + self.assertEqual( + generate_cv.resolve_bullets({}, self.SOURCE, self.PATH), + ["alpha", "beta", "gamma"], + ) + + def test_out_of_range_index_fails(self): + with self.assertRaises(generate_cv.ConfigError): + generate_cv.resolve_bullets( + {"bullets": [{"use": 4}]}, self.SOURCE, self.PATH + ) + + +class TestSkillValidation(unittest.TestCase): + def test_unknown_skill_warns(self): + warnings = [] + generate_cv.validate_skills( + [{"group": "X", "items": ["Definitely Not A Skill"]}], [], warnings.append + ) + self.assertEqual(len(warnings), 1) + + def test_extra_skills_allowlist_silences(self): + warnings = [] + generate_cv.validate_skills( + [{"group": "X", "items": ["Definitely Not A Skill"]}], + ["Definitely Not A Skill"], + warnings.append, + ) + self.assertEqual(warnings, []) + + def test_parenthetical_alias_grounds_label(self): + warnings = [] + # "Go (Golang)" grounds via "Go"; "DIDs" via the sources' + # "Decentralized Identifiers (DIDs)". + generate_cv.validate_skills( + [{"group": "X", "items": ["Go (Golang)", "DIDs"]}], [], warnings.append + ) + self.assertEqual(warnings, []) + + +class TestShippedRoleConfigs(unittest.TestCase): + """Every role config in documents/cv/roles/ must generate cleanly.""" + + def generate_all(self): + results = {} + with tempfile.TemporaryDirectory() as tmp: + for role in generate_cv.list_roles(): + warnings = [] + target = generate_cv.generate( + role, output=Path(tmp) / f"{role}.tex", warn=warnings.append + ) + results[role] = (target.read_text(encoding="utf-8"), warnings) + return results + + def test_expected_roles_exist(self): + self.assertLessEqual( + { + "example", + "staff-software-engineer", + "senior-software-engineer", + "founding-software-engineer", + }, + set(generate_cv.list_roles()), + ) + + def test_configs_generate_without_grounding_warnings(self): + for role, (_, warnings) in self.generate_all().items(): + self.assertEqual(warnings, [], f"{role}: ungrounded skills: {warnings}") + + def test_output_is_wellformed_latex(self): + for role, (text, _) in self.generate_all().items(): + body = "\n".join( + line for line in text.splitlines() if not line.startswith("%%") + ) + self.assertIn("\\begin{document}", body, role) + self.assertIn("\\end{document}", body, role) + self.assertIn("prnk28@gmail.com", body, role) + # F9 guard: no bullet parsed as an optional label. + self.assertIsNone(UNBRACED_BRACKET_ITEM.search(body), role) + # No unescaped % (silent truncation) or & (compile error). + self.assertIsNone(re.search(r"(?