{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c952b5aa",
   "metadata": {},
   "source": [
    "# 3.3 — Testing Your Data\n",
    "\n",
    "**Goal:** write tests that save data, load it back, and compare. Then break the code on purpose and watch a test catch it.\n",
    "\n",
    "**How to use this notebook**\n",
    "\n",
    "- Run cells **top to bottom**. Click a cell and press **Shift + Enter**.\n",
    "- Cells marked **TODO** are yours. Replace the `None`, `pass` or empty list with your code.\n",
    "- Under each TODO is a **check cell**. It stays quiet when your code is right. When it's wrong, it tells you what to look at. Read that message before you ask for help.\n",
    "- Each part starts with a worked example, then gives you practice in different contexts (library, gradebook, bike shop, budget and so on). Notice the *pattern* repeating across them.\n",
    "- Finished? Run **Part: Tidy up** so your practice files don't pile up."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7936061d",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import os\n",
    "\n",
    "def save_data(data, filename):\n",
    "    \"\"\"Write the whole data set to a JSON file, replacing what was there.\"\"\"\n",
    "    with open(filename, \"w\") as f:\n",
    "        json.dump(data, f, indent=2)\n",
    "\n",
    "def load_data(filename):\n",
    "    \"\"\"Read a JSON file and return the data it holds.\"\"\"\n",
    "    with open(filename) as f:\n",
    "        return json.load(f)\n",
    "\n",
    "def load_or_start(filename):\n",
    "    \"\"\"Load the file if it exists. On a first run, return an empty dictionary.\"\"\"\n",
    "    if os.path.exists(filename):\n",
    "        return load_data(filename)\n",
    "    return {}\n",
    "\n",
    "import io, contextlib\n",
    "\n",
    "def check_case(label, got, expected):\n",
    "    \"\"\"Print PASS or FAIL for one test case, without stopping the program.\"\"\"\n",
    "    if got == expected:\n",
    "        print(\"PASS: \" + label)\n",
    "    else:\n",
    "        print(\"FAIL: \" + label + \". Expected \" + repr(expected) + \", got \" + repr(got))\n",
    "\n",
    "def printed_pass(test_function, *args):\n",
    "    \"\"\"Run a test and return True if it printed PASS.\"\"\"\n",
    "    buf = io.StringIO()\n",
    "    with contextlib.redirect_stdout(buf):\n",
    "        test_function(*args)\n",
    "    return \"PASS\" in buf.getvalue()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c1ff7567",
   "metadata": {},
   "source": [
    "## Part 1 — The round-trip test\n",
    "\n",
    "The most important test for a saved app is: **save it, load it, compare.** `assert` stops the program with an error if the condition is False. The message after the comma tells you what failed.\n",
    "\n",
    "Run this example. It should print PASS."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "824bb437",
   "metadata": {},
   "outputs": [],
   "source": [
    "def test_round_trip():\n",
    "    original = {\"apple\": {\"qty\": 4, \"price\": 0.50}}\n",
    "    save_data(original, \"test_round_trip.json\")\n",
    "    loaded = load_data(\"test_round_trip.json\")\n",
    "    assert loaded == original, \"Data changed after save and load\"\n",
    "    print(\"PASS: round trip\")\n",
    "\n",
    "test_round_trip()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b67337c1",
   "metadata": {},
   "source": [
    "Note: this test uses its own file name. Your tests should never touch your real save file."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9fe4ce64",
   "metadata": {},
   "source": [
    "## Practice A: a round-trip test for a gradebook\n",
    "\n",
    "Write `test_gradebook_round_trip`. Make a small gradebook, save it to `test_gradebook.json`, load it back, and `assert` they match. Print PASS when it works."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8ff42e79",
   "metadata": {},
   "outputs": [],
   "source": [
    "def test_gradebook_round_trip():\n",
    "    \"\"\"Save a gradebook, load it back, and check nothing changed.\"\"\"\n",
    "    # TODO: make a small gradebook, save it to \"test_gradebook.json\",\n",
    "    # load it back, assert it matches, then print \"PASS: gradebook round trip\"\n",
    "    pass"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "84c4e108",
   "metadata": {},
   "outputs": [],
   "source": [
    "assert printed_pass(test_gradebook_round_trip), \"your test should print PASS when the data matches\"\n",
    "assert os.path.exists(\"test_gradebook.json\"), \"your test should save to test_gradebook.json\"\n",
    "print(\"✅ Practice A done\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c974d320",
   "metadata": {},
   "source": [
    "## Practice B: fill in a test table\n",
    "\n",
    "This is the test table from your README, as code. Each case has an input and an expected result. Run `add_item_safe` on each input, record what **actually** happened in `actual`, and set `passed`.\n",
    "\n",
    "Record what really happens, even if it's a fail."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4633fc00",
   "metadata": {},
   "outputs": [],
   "source": [
    "def add_item_safe(inventory, name, qty):\n",
    "    \"\"\"Add qty to an item. Return the inventory, or an error message if qty is not a whole number of 0 or more.\"\"\"\n",
    "    if not isinstance(qty, int) or qty < 0:\n",
    "        return \"Quantity must be a whole number, 0 or more\"\n",
    "    inventory[name] = inventory.get(name, 0) + qty\n",
    "    return inventory"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "43ea2eee",
   "metadata": {},
   "outputs": [],
   "source": [
    "cases = [\n",
    "    {\"id\": 1, \"what\": \"normal add\", \"input\": 5, \"expected\": {\"apple\": 5}},\n",
    "    {\"id\": 2, \"what\": \"negative qty\", \"input\": -5, \"expected\": \"Quantity must be a whole number, 0 or more\"},\n",
    "    {\"id\": 3, \"what\": \"text qty\", \"input\": \"five\", \"expected\": \"Quantity must be a whole number, 0 or more\"},\n",
    "    {\"id\": 4, \"what\": \"zero qty\", \"input\": 0, \"expected\": {\"apple\": 0}},\n",
    "]\n",
    "# TODO: for each case, set case[\"actual\"] to add_item_safe({}, \"apple\", case[\"input\"])\n",
    "# TODO: set case[\"passed\"] to True if actual == expected. Print each row."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9f8c6ff7",
   "metadata": {},
   "outputs": [],
   "source": [
    "assert all(\"actual\" in c and \"passed\" in c for c in cases), \"fill in actual and passed for every case\"\n",
    "assert sum(c[\"passed\"] for c in cases) == 4, \"all four cases should pass with this function\"\n",
    "print(\"✅ Practice B done\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4325e0b0",
   "metadata": {},
   "source": [
    "## Practice C: test the first run\n",
    "\n",
    "Write `test_first_run`. Delete `test_first_run.json` if it exists, call `load_or_start` on it, and `assert` the result is empty. Print PASS when it works."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0027a52a",
   "metadata": {},
   "outputs": [],
   "source": [
    "def test_first_run():\n",
    "    # TODO: delete test_first_run.json if it exists, call load_or_start on it,\n",
    "    # assert the result is {}, then print \"PASS: first run starts empty\"\n",
    "    pass"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a3ceb527",
   "metadata": {},
   "outputs": [],
   "source": [
    "assert printed_pass(test_first_run), \"your test should print PASS when the first run starts empty\"\n",
    "print(\"✅ Practice C done\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4742c231",
   "metadata": {},
   "source": [
    "## Practice D: break it on purpose\n",
    "\n",
    "Here's a save function with a bug in it. It forgets the last record."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1eb985b9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def save_partial(data, filename):\n",
    "    \"\"\"BUGGY on purpose: forgets the last record.\"\"\"\n",
    "    trimmed = dict(list(data.items())[:-1])\n",
    "    save_data(trimmed, filename)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "05b083e0",
   "metadata": {},
   "source": [
    "Write `test_saves_everything`. It takes a `save_function` as a parameter, so the same test can check any save function. Make a 3-record dictionary, save it with `save_function`, load it back and `assert` every record came back. Print PASS if it does."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cd3a1aa1",
   "metadata": {},
   "outputs": [],
   "source": [
    "def test_saves_everything(save_function):\n",
    "    # TODO: make a 3-record dictionary, call save_function(data, \"test_all.json\"),\n",
    "    # load it back, assert it matches, then print \"PASS: all records saved\"\n",
    "    pass"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2fb96e34",
   "metadata": {},
   "outputs": [],
   "source": [
    "assert printed_pass(test_saves_everything, save_data), \"the test should PASS with the real save_data\"\n",
    "bug_caught = False\n",
    "try:\n",
    "    test_saves_everything(save_partial)\n",
    "except AssertionError:\n",
    "    bug_caught = True\n",
    "assert bug_caught, \"the test should FAIL (an AssertionError) with the buggy save_partial\"\n",
    "print(\"✅ Bug caught. Your test is doing its job.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5f9bb7b3",
   "metadata": {},
   "source": [
    "## Part 2 — Which one?\n",
    "\n",
    "Replace each `None` with `\"A\"` or `\"B\"`.\n",
    "\n",
    "- **Q1:** What's the most important test for a saved app? A) check the title is right  B) save, load, compare\n",
    "- **Q2:** Which file name should your test use? A) your real save file  B) a separate test file name"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0ea87af2",
   "metadata": {},
   "outputs": [],
   "source": [
    "q1 = None   # \"A\" or \"B\"\n",
    "q2 = None   # \"A\" or \"B\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a559ede6",
   "metadata": {},
   "outputs": [],
   "source": [
    "assert q1 == \"B\", \"Q1: save, load, compare is the test that catches lost data.\"\n",
    "assert q2 == \"B\", \"Q2: a test should never be able to damage your real data.\"\n",
    "print(\"✅ Part 2 done\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a398b255",
   "metadata": {},
   "source": [
    "## Tidy up"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "74df714e",
   "metadata": {},
   "outputs": [],
   "source": [
    "for f in [\"test_round_trip.json\", \"test_gradebook.json\", \"test_first_run.json\", \"test_all.json\"]:\n",
    "    if os.path.exists(f):\n",
    "        os.remove(f)\n",
    "print(\"Tidied up.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ac097015",
   "metadata": {},
   "source": [
    "## You're done with this lesson when…\n",
    "\n",
    "- [ ] I wrote a round-trip test and it passes\n",
    "- [ ] I filled in a test table with actual results\n",
    "- [ ] I wrote a test that checks a bad case, not only the easy path\n",
    "- [ ] I saw a test fail on purpose and understood why\n",
    "- [ ] My tests use their own file names"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
