{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 2 — Automated Testing, Code Quality & CI\n",
    "\n",
    "**Companion notebook** for the Lesson 2 slides. You'll write a pytest suite for a small\n",
    "pricing module, prove it is strong by running it against **mutants** (copies of the\n",
    "module with one deliberate bug each), add a property-based test with hypothesis, lint the\n",
    "monolith with ruff, and write a GitHub Actions workflow.\n",
    "\n",
    "Tests live in files, so this notebook writes them to a scratch folder and runs pytest\n",
    "there, exactly as you would in a terminal."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import json\n",
    "import re\n",
    "import subprocess\n",
    "import sys\n",
    "import tempfile\n",
    "import textwrap\n",
    "from pathlib import Path\n",
    "\n",
    "import yaml\n",
    "\n",
    "WORK = Path(tempfile.mkdtemp())\n",
    "MONOLITH = Path(\"../starter/monolith/price_book.py\")\n",
    "\n",
    "PRICING = '''\n",
    "from decimal import ROUND_HALF_UP, Decimal\n",
    "\n",
    "BASE_LIAB = 938.09\n",
    "STYLE = {\"Sedan\": 1.00, \"SUV\": 1.05, \"Hatchback\": 1.10, \"Pickup\": 0.90, \"Minivan\": 0.85, \"Sports\": 1.40}\n",
    "\n",
    "\n",
    "def money(amount):\n",
    "    \"\"\"Round half up, to the cent.\"\"\"\n",
    "    return float(Decimal(repr(amount)).quantize(Decimal(\"0.01\"), rounding=ROUND_HALF_UP))\n",
    "\n",
    "\n",
    "def age_factor(age):\n",
    "    for upper, factor in [(25, 1.60), (30, 1.15), (65, 1.00), (75, 1.10), (200, 1.35)]:\n",
    "        if age < upper:\n",
    "            return factor\n",
    "\n",
    "\n",
    "def mileage_factor(miles):\n",
    "    for upper, factor in [(7_500, 0.85), (15_000, 1.00), (float(\"inf\"), 1.20)]:\n",
    "        if miles < upper:\n",
    "            return factor\n",
    "\n",
    "\n",
    "def liability_premium(style, age, miles):\n",
    "    return money(BASE_LIAB * STYLE[style] * age_factor(age) * mileage_factor(miles))\n",
    "\n",
    "\n",
    "def earned_premium(annual, exposure):\n",
    "    if not 0 <= exposure <= 1:\n",
    "        raise ValueError(f\"exposure must be between 0 and 1, got {exposure}\")\n",
    "    return money(annual * exposure)\n",
    "'''\n",
    "\n",
    "MUTANTS = {\n",
    "    \"25 counted as young\": (\"if age < upper:\\n            return factor\\n\\n\\ndef mileage\",\n",
    "                            \"if age < upper or (upper == 25 and age < 26):\\n            return factor\\n\\n\\ndef mileage\"),\n",
    "    \"15,000 miles not high\": (\"(15_000, 1.00)\", \"(15_001, 1.00)\"),\n",
    "    \"no exposure check\": (\"    if not 0 <= exposure <= 1:\\n\", \"    if False:\\n\"),\n",
    "    \"rounds halves to even\": (\"return float(Decimal(repr(amount)).quantize(Decimal(\\\"0.01\\\"), rounding=ROUND_HALF_UP))\",\n",
    "                              \"return round(amount, 2)\"),\n",
    "    \"sports factor lost\": ('\"Sports\": 1.40', '\"Sports\": 1.00'),\n",
    "    \"high mileage discount\": ('(float(\"inf\"), 1.20)', '(float(\"inf\"), 0.95)'),\n",
    "}\n",
    "\n",
    "\n",
    "def mutant(name):\n",
    "    old, new = MUTANTS[name]\n",
    "    assert old in PRICING, name\n",
    "    return PRICING.replace(old, new)\n",
    "\n",
    "\n",
    "def run_tests(module_source, tests, name=\"t\"):\n",
    "    \"\"\"Write pricing.py and test_pricing.py to a fresh folder, run pytest; return (passed, failed).\"\"\"\n",
    "    folder = WORK / name\n",
    "    folder.mkdir(exist_ok=True)\n",
    "    (folder / \"pricing.py\").write_text(module_source)\n",
    "    (folder / \"test_pricing.py\").write_text(textwrap.dedent(tests))\n",
    "    out = subprocess.run([sys.executable, \"-m\", \"pytest\", \"-q\", \"-p\", \"no:cacheprovider\", \"test_pricing.py\"],\n",
    "                         cwd=folder, capture_output=True, text=True).stdout\n",
    "    passed = int(m.group(1)) if (m := re.search(r\"(\\d+) passed\", out)) else 0\n",
    "    failed = int(m.group(1)) if (m := re.search(r\"(\\d+) failed\", out)) else 0\n",
    "    return passed, failed\n",
    "\n",
    "\n",
    "def kills(tests):\n",
    "    \"\"\"Names of the mutants the suite catches (at least one failing test).\"\"\"\n",
    "    return [name for name in MUTANTS if run_tests(mutant(name), tests, name.replace(\" \", \"_\"))[1] > 0]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "## 1. A first test\n",
    "\n",
    "A test is a function whose name starts with `test_` and which `assert`s something."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "FIRST = '''\n",
    "from pricing import liability_premium\n",
    "\n",
    "def test_base_risk_pays_the_base_rate():\n",
    "    assert liability_premium(\"Sedan\", 40, 12_000) == 938.09\n",
    "'''\n",
    "print(\"correct module (passed, failed):\", run_tests(PRICING, FIRST, \"first\"))\n",
    "print(\"mutants caught:\", kills(FIRST))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "A passing suite proves little: this one catches none of the six mutants. The bugs sit\n",
    "at the **edges**: band boundaries, impossible inputs, half cents.\n",
    "\n",
    "---\n",
    "## Your turn 1 — a suite that kills the mutants\n",
    "\n",
    "Write `TESTS` (the text of a test file) so that it passes on the correct module and\n",
    "fails on as many mutants as you can. Suggestions:\n",
    "- `@pytest.mark.parametrize` over age and mileage **boundaries** (24.99, 25, 29.99, 30, …;\n",
    "  7,499, 7,500, 14,999, 15,000)\n",
    "- a hand-calculated premium for a young driver in a sports car\n",
    "- `pytest.raises(ValueError)` for exposures of −0.25 and 1.5\n",
    "- `money(836.645) == 836.65`\n",
    "\n",
    "You don't need a test per mutant: look at which ones survive and add what's missing."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "TESTS = FIRST\n",
    "...  # your code here\n",
    "passed, failed = run_tests(PRICING, TESTS, \"yours\")\n",
    "killed = kills(TESTS)\n",
    "print(f\"correct module: {passed} passed, {failed} failed\")\n",
    "print(\"caught:\", killed)\n",
    "print(\"survived:\", [m for m in MUTANTS if m not in killed])\n",
    "check(\"suite passes on the correct module\", failed == 0 and passed > 0, True)\n",
    "check(\"mutants caught\", len(killed), 6)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "## Your turn 2 — a property, not an example\n",
    "\n",
    "Example tests check the cases you thought of. A **property** must hold for every input;\n",
    "hypothesis generates hundreds and shrinks any failure to the simplest one. Write\n",
    "`PROPERTY`: a test file with one test, decorated with `@given`, asserting that **more\n",
    "miles never cost less** (for any style, any age 16–110, any mileage 1–150,000 and any\n",
    "extra 0–50,000 miles). It should pass on the correct module and catch the\n",
    "\"high mileage discount\" mutant on its own.\n",
    "\n",
    "In CI, use `@settings(derandomize=True)`: the same examples every run, so a test can't\n",
    "pass on Monday and fail on Tuesday with no code change."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "PROPERTY = FIRST\n",
    "...  # your code here\n",
    "ok_correct = run_tests(PRICING, PROPERTY, \"prop\")\n",
    "ok_mutant = run_tests(mutant(\"high mileage discount\"), PROPERTY, \"prop_mutant\")\n",
    "print(\"correct:\", ok_correct, \"· mutant:\", ok_mutant)\n",
    "check(\"property holds on the correct module\", ok_correct[1] == 0 and ok_correct[0] == 1, True)\n",
    "check(\"property catches the discount mutant\", ok_mutant[1], 1)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "## Your turn 3 — lint the monolith\n",
    "\n",
    "Run ruff on the monolith, isolated from any configuration, with the rule families\n",
    "`E,F,W,I,B,UP,SIM,RUF`, line length 100, and JSON output:\n",
    "`ruff check --isolated --select … --line-length 100 --output-format json <file>`.\n",
    "(`[sys.executable, \"-m\", \"ruff\", …]` finds the ruff installed with this kernel.)\n",
    "Store the list of rule codes found in `codes`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "codes = None\n",
    "...  # your code here\n",
    "check(\"ruff findings\", None if codes is None else len(codes), 8)\n",
    "check(\"unused import found\", None if codes is None else \"F401\" in codes, True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-010",
   "metadata": {},
   "source": [
    "## Your turn 4 — a CI workflow\n",
    "\n",
    "Write `WORKFLOW`, a GitHub Actions file (YAML) that runs on every **push** to `main` and\n",
    "every **pull request**, with one job `test` on `ubuntu-latest` whose steps:\n",
    "1. check out the code (`actions/checkout@v7`)\n",
    "2. install uv (`astral-sh/setup-uv@v10`)\n",
    "3. `uv sync --locked`\n",
    "4. `uv run ruff check .`\n",
    "5. `uv run mypy src tests`\n",
    "6. `uv run pytest --cov`\n",
    "\n",
    "`ci_ok` below parses it and checks those requirements."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-011",
   "metadata": {},
   "outputs": [],
   "source": [
    "WORKFLOW = \"\"\n",
    "...  # your code here\n",
    "\n",
    "\n",
    "def ci_ok(text):\n",
    "    wf = yaml.safe_load(text) or {}\n",
    "    triggers = wf.get(\"on\", wf.get(True, {}))      # YAML 1.1 reads a bare `on:` key as True\n",
    "    steps = wf.get(\"jobs\", {}).get(\"test\", {}).get(\"steps\", [])\n",
    "    uses = [s.get(\"uses\", \"\") for s in steps]\n",
    "    runs = \" | \".join(s.get(\"run\", \"\") for s in steps)\n",
    "    return (isinstance(triggers, dict) and \"push\" in triggers and \"pull_request\" in triggers\n",
    "            and any(u.startswith(\"actions/checkout\") for u in uses)\n",
    "            and all(cmd in runs for cmd in [\"uv sync --locked\", \"ruff check\", \"mypy\", \"pytest\"]))\n",
    "\n",
    "\n",
    "check(\"workflow meets the requirements\", ci_ok(WORKFLOW), True)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
