{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: карта лояльности\n",
    "\n",
    "Класс LoyaltyCard: баллы только для чтения, начисление и списание с проверками, уровень карты — вычисляемое свойство. Покупки нескольких клиентов и золотые карты.\n",
    "\n",
    "Курс ООП в Python · урок `oop-p3-loyalty` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/p3-loyalty).\n",
    "\n",
    "Выполняйте ячейки по порядку (Shift+Enter), начиная с первой — она готовит урок. После каждого упражнения идёт\n",
    "ячейка проверки: она печатает ✓ / ✗ и итог."
   ],
   "id": "intro"
  },
  {
   "cell_type": "code",
   "metadata": {
    "cellView": "form",
    "jupyter": {
     "source_hidden": true
    }
   },
   "source": [
    "# @title Подготовка урока — выполните эту ячейку первой\n",
    "# Проверяет версии пакетов, записывает файлы данных в папку data/ и определяет _check — проверку упражнений.\n",
    "import base64, os, zlib\n",
    "\n",
    "import sys\n",
    "\n",
    "if sys.version_info < (3, 10):\n",
    "    print(f\"Урок написан для Python 3.10 и новее, у вас {sys.version.split()[0]} — часть кода может не работать\")\n",
    "\n",
    "# ─── Проверка упражнений: тот же раннер, что у задач на сайте ───\n",
    "# ─── Запуск тестов ───────────────────────────────────────────────────────────\n",
    "\n",
    "_TIMEOUT = 2.0  # секунд на тест; отдельный тест может задать свой: test_x.timeout = 10\n",
    "\n",
    "\n",
    "def _call_with_timeout(target, seconds: float) -> bool:\n",
    "    \"\"\"Запускает target() в отдельном потоке. Возвращает False, если за seconds он не завершился.\"\"\"\n",
    "    import sys\n",
    "    import threading\n",
    "\n",
    "    if sys.platform == \"emscripten\":  # Python в браузере: потоков нет, таймаут обеспечивает страница\n",
    "        target()\n",
    "        return True\n",
    "    # Большой стек нужен, чтобы глубокая рекурсия давала RecursionError, а не аварийное завершение Python.\n",
    "    for megabytes in (256, 64, 0):\n",
    "        try:\n",
    "            threading.stack_size(megabytes * 1024 * 1024)\n",
    "            worker = threading.Thread(target=target, daemon=True)\n",
    "            worker.start()\n",
    "        except (ValueError, RuntimeError, MemoryError):\n",
    "            continue\n",
    "        worker.join(seconds)\n",
    "        return not worker.is_alive()\n",
    "    target()  # потоки недоступны — запускаем без таймаута\n",
    "    return True\n",
    "\n",
    "\n",
    "def _test_info(fn) -> dict:\n",
    "    \"\"\"Описание теста, его лимит времени и сообщение на случай зависания.\"\"\"\n",
    "    limit = getattr(fn, \"timeout\", _TIMEOUT)\n",
    "    hint = getattr(fn, \"timeout_hint\", \"\")\n",
    "    message = (\n",
    "        f\"тест не завершился за {limit:g} с — возможно, бесконечный цикл или рекурсия: \"\n",
    "        \"проверьте, что границы/аргументы изменяются на каждом шаге\" + (f\". {hint}\" if hint else \"\")\n",
    "    )\n",
    "    return {\"title\": (fn.__doc__ or fn.__name__).strip(), \"timeout\": limit, \"timeout_message\": message}\n",
    "\n",
    "\n",
    "def _error_result(title: str, error: BaseException, user_file: str | None) -> dict:\n",
    "    \"\"\"Результат упавшего теста: статус, сообщение, тип исключения и строка в коде решения.\"\"\"\n",
    "    line = None\n",
    "    if user_file:  # самый глубокий кадр в коде решения; ошибка внутри самих тестов — без номера строки\n",
    "        tb = error.__traceback__\n",
    "        while tb is not None:\n",
    "            if tb.tb_frame.f_code.co_filename == user_file:\n",
    "                line = tb.tb_lineno\n",
    "            tb = tb.tb_next\n",
    "    status, message = \"error\", f\"{type(error).__name__}: {error}\"\n",
    "    if isinstance(error, NotImplementedError):\n",
    "        status, message, line = \"not_written\", \"функция ещё не написана\", None\n",
    "    elif isinstance(error, AssertionError):\n",
    "        status, message = \"failed\", str(error) or \"проверка не прошла\"\n",
    "    elif isinstance(error, RecursionError):\n",
    "        message = \"RecursionError: слишком глубокая рекурсия — проверьте базовый случай и то, что задача уменьшается\"\n",
    "    elif isinstance(error, MemoryError) or (isinstance(error, OverflowError) and \"index-sized\" in str(error)):\n",
    "        message = f\"{type(error).__name__}: не хватило памяти — возможно, создаётся слишком большая структура данных\"\n",
    "    return {\"title\": title, \"status\": status, \"message\": message, \"error_type\": type(error).__name__, \"line\": line}\n",
    "\n",
    "\n",
    "def _run_tests(verbose: bool = True, on_start=None, on_result=None, user_file: str | None = None) -> list[dict]:\n",
    "    \"\"\"Запускает тесты задачи (_TESTS — функции из tests.py) по порядку, каждый — с таймаутом.\n",
    "\n",
    "    Возвращает список результатов: {\"title\", \"status\", \"message\", \"error_type\", \"line\"}, где status —\n",
    "    passed · failed · error · not_written · timeout · not_run. Если тест завис, при verbose=True печатает\n",
    "    итог и завершает процесс: зависший поток остановить нельзя. on_start(i) и on_result(i, результат)\n",
    "    вызываются по мере выполнения; user_file — имя файла с кодом решения для номеров строк в ошибках.\n",
    "    \"\"\"\n",
    "    import os\n",
    "    import sys\n",
    "\n",
    "    if verbose:\n",
    "        try:  # ✓ и кириллица в консоли Windows со старой кодировкой\n",
    "            sys.stdout.reconfigure(encoding=\"utf-8\")\n",
    "        except Exception:  # noqa: BLE001 — reconfigure может не быть (перенаправленный вывод, браузер)\n",
    "            pass\n",
    "\n",
    "    tests = list(_TESTS)\n",
    "    results: list[dict] = []\n",
    "    not_written = hung = False\n",
    "    for index, fn in enumerate(tests):\n",
    "        info = _test_info(fn)\n",
    "        outcome: dict = {}\n",
    "\n",
    "        def target(fn=fn, outcome=outcome):\n",
    "            try:\n",
    "                fn()\n",
    "            except BaseException as e:\n",
    "                outcome[\"error\"] = e\n",
    "\n",
    "        if on_start:\n",
    "            on_start(index)\n",
    "        if not _call_with_timeout(target, info[\"timeout\"]):\n",
    "            results.append({\"title\": info[\"title\"], \"status\": \"timeout\", \"message\": info[\"timeout_message\"], \"error_type\": None, \"line\": None})\n",
    "            for other in tests[index + 1:]:\n",
    "                results.append({\"title\": _test_info(other)[\"title\"], \"status\": \"not_run\", \"message\": \"не запускался: предыдущий тест завис\", \"error_type\": None, \"line\": None})\n",
    "            hung = True\n",
    "            break\n",
    "        error = outcome.get(\"error\")\n",
    "        if error is None:\n",
    "            result = {\"title\": info[\"title\"], \"status\": \"passed\", \"message\": \"\", \"error_type\": None, \"line\": None}\n",
    "        else:\n",
    "            result = _error_result(info[\"title\"], error, user_file)\n",
    "            not_written = not_written or result[\"status\"] == \"not_written\"\n",
    "        results.append(result)\n",
    "        if on_result:\n",
    "            on_result(index, result)\n",
    "\n",
    "    if not_written:  # пока функция не написана, «даром» пройденные тесты не засчитываются\n",
    "        for r in results:\n",
    "            if r[\"status\"] == \"passed\":\n",
    "                r.update(status=\"not_written\", message=\"не засчитан, пока функция не написана\")\n",
    "    for r in results:\n",
    "        if len(r[\"message\"]) > 300:\n",
    "            r[\"message\"] = r[\"message\"][:300] + \"…\"\n",
    "\n",
    "    if verbose:\n",
    "        if not_written:\n",
    "            print(\"Функция ещё не написана — замените raise NotImplementedError своим кодом.\\n\")\n",
    "        for r in results:\n",
    "            mark = {\"passed\": \"✓\", \"not_run\": \"–\"}.get(r[\"status\"], \"✗\")\n",
    "            print(f\"{mark} {r['title']}\")\n",
    "            if r[\"message\"]:\n",
    "                print(f\"    {r['message']}\")\n",
    "        passed = sum(r[\"status\"] == \"passed\" for r in results)\n",
    "        print(\"─\" * 40)\n",
    "        print(f\"Прошло {passed} из {len(results)}\" + (\" — всё верно!\" if passed == len(results) else \"\"))\n",
    "        if hung:\n",
    "            print(\"Выполнение остановлено: один из тестов завис.\")\n",
    "            sys.stdout.flush()\n",
    "            os._exit(1)\n",
    "    return results\n",
    "\n",
    "\n",
    "def _check(*tests, names=()):\n",
    "    \"\"\"Запускает проверки упражнения и печатает ✓ / ✗ и итог — как на сайте.\"\"\"\n",
    "    for name in names:\n",
    "        if \".\" in name:  # «Класс.метод» из заготовки: класс должен быть объявлен\n",
    "            if not isinstance(globals().get(name.split(\".\")[0]), type):\n",
    "                print(f\"✗ класс {name.split('.')[0]} не объявлен — выполните ячейку упражнения\")\n",
    "                return\n",
    "            continue\n",
    "        if name not in globals():\n",
    "            print(f\"✗ переменная {name} не создана — выполните ячейку упражнения\")\n",
    "            return\n",
    "        if globals()[name] is Ellipsis:\n",
    "            print(f\"✗ {name} пока равна ... — замените многоточие своим кодом\")\n",
    "            return\n",
    "    globals()[\"_TESTS\"] = list(tests)\n",
    "    _run_tests()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "setup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "«Кофейная лавка» запускает карту лояльности. С каждой покупки на карту начисляется 5 % суммы баллами\n",
    "(округление вниз), баллами можно оплатить часть следующих покупок. Чем больше клиент потратил за всё время,\n",
    "тем выше уровень карты: до 5000 ₽ — «базовый», до 20 000 ₽ — «серебряный», от 20 000 ₽ — «золотой».\n",
    "\n",
    "Баллы — те же деньги, поэтому главное требование — **через публичный интерфейс баланс нельзя изменить в обход\n",
    "правил**.\n",
    "В проекте пригодится весь модуль: внутренние атрибуты, свойства только для чтения, проверки и `raise`.\n",
    "\n",
    "## Шаг 1. Карта и начисление"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Класс LoyaltyCard\n",
    "\n",
    "Допишите класс `LoyaltyCard`. `LoyaltyCard(\"Анна\")` — новая карта: владелец в атрибуте `owner`, баллов 0,\n",
    "покупок на 0 ₽. Свойства только для чтения: `points` — текущие баллы, `total` — сумма всех покупок.\n",
    "\n",
    "Метод `earn(amount)` регистрирует покупку на `amount` рублей: прибавляет её к сумме покупок, начисляет\n",
    "баллы — 5 % суммы с округлением вниз — и возвращает число начисленных баллов. Если сумма не больше нуля —\n",
    "`ValueError`, и на карте ничего не меняется.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Внутренние атрибуты — `_points` и `_total`; свойства их только возвращают.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "5 % с округлением вниз — целочисленное деление: `amount * 5 // 100`.\n",
    "\n",
    "</details>"
   ],
   "id": "card-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class LoyaltyCard:\n",
    "    def __init__(self, owner):\n",
    "        ...\n",
    "\n",
    "    @property\n",
    "    def points(self):\n",
    "        ...\n",
    "\n",
    "    @property\n",
    "    def total(self):\n",
    "        ...\n",
    "\n",
    "    def earn(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "card"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def _card():\n",
    "    try:\n",
    "        return LoyaltyCard(\"Анна\")\n",
    "    except TypeError as e:\n",
    "        assert False, f\"LoyaltyCard(\\\"Анна\\\") не создаётся: {e}\"\n",
    "\n",
    "\n",
    "def test_new():\n",
    "    \"новая карта: владелец, 0 баллов, 0 ₽ покупок\"\n",
    "    for name in [\"points\", \"total\"]:\n",
    "        assert isinstance(LoyaltyCard.__dict__.get(name), property), f\"{name} должен быть свойством — с декоратором @property\"\n",
    "    card = _card()\n",
    "    assert getattr(card, \"owner\", None) == \"Анна\", \"владелец хранится в атрибуте owner\"\n",
    "    assert card.points == 0 and card.total == 0, f\"у новой карты points = {card.points!r}, total = {card.total!r}, а нужно 0 и 0\"\n",
    "\n",
    "\n",
    "def test_read_only():\n",
    "    \"points и total нельзя записать снаружи\"\n",
    "    for name in [\"points\", \"total\"]:\n",
    "        prop = LoyaltyCard.__dict__.get(name)\n",
    "        assert isinstance(prop, property), f\"{name} должен быть свойством\"\n",
    "        assert prop.fset is None, f\"у свойства {name} есть сеттер — баллы и сумма меняются только через earn\"\n",
    "\n",
    "\n",
    "def test_earn():\n",
    "    \"earn начисляет 5 % с округлением вниз\"\n",
    "    card = _card()\n",
    "    got = card.earn(1250)\n",
    "    assert got is not None, \"earn ничего не возвращает — верните число начисленных баллов\"\n",
    "    assert got != 62.5, \"баллы — целые: округлите вниз целочисленным делением //\"\n",
    "    assert got == 62, f\"earn(1250) вернул {got!r}, а 5 % от 1250 с округлением вниз — 62\"\n",
    "    card.earn(399)\n",
    "    assert card.points == 62 + 19, f\"после покупок на 1250 и 399 ₽ баллов {card.points}, а нужно 81\"\n",
    "    assert card.total == 1649, f\"после покупок на 1250 и 399 ₽ total = {card.total}, а нужно 1649\"\n",
    "\n",
    "\n",
    "def test_earn_invalid():\n",
    "    \"сумма 0 и меньше — ValueError, карта не меняется\"\n",
    "    card = _card()\n",
    "    card.earn(1000)\n",
    "    for bad in [0, -500]:\n",
    "        try:\n",
    "            card.earn(bad)\n",
    "        except ValueError:\n",
    "            pass\n",
    "        else:\n",
    "            assert False, f\"earn({bad}) не выбросил ValueError\"\n",
    "        assert (card.points, card.total) == (50, 1000), f\"после earn({bad}) points = {card.points}, total = {card.total} — карта не должна меняться\"\n",
    "\n",
    "_check(test_new, test_read_only, test_earn, test_earn_invalid, names=[\"LoyaltyCard.__init__\",\"LoyaltyCard.points\",\"LoyaltyCard.total\",\"LoyaltyCard.earn\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "card-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class LoyaltyCard:\n",
    "    def __init__(self, owner):\n",
    "        self.owner = owner\n",
    "        self._points = 0\n",
    "        self._total = 0\n",
    "\n",
    "    @property\n",
    "    def points(self):\n",
    "        return self._points\n",
    "\n",
    "    @property\n",
    "    def total(self):\n",
    "        return self._total\n",
    "\n",
    "    def earn(self, amount):\n",
    "        if amount <= 0:\n",
    "            raise ValueError(\"сумма покупки должна быть больше нуля\")\n",
    "        self._total += amount\n",
    "        earned = amount * 5 // 100\n",
    "        self._points += earned\n",
    "        return earned\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "card-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Уровень и списание"
   ],
   "id": "text-32"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Уровень карты и списание баллов\n",
    "\n",
    "Добавьте в класс (скопируйте его из шага 1 и допишите):\n",
    "\n",
    "- свойство `level` — уровень по сумме покупок `total`: меньше 5000 — `\"базовый\"`, меньше 20 000 —\n",
    "  `\"серебряный\"`, иначе — `\"золотой\"`;\n",
    "- метод `spend(points)` — списывает баллы. Если `points` не больше нуля — `ValueError(\"списать можно только\n",
    "  положительное число баллов\")`, если баллов на карте меньше — `ValueError(\"недостаточно баллов\")`; в обоих\n",
    "  случаях баланс не меняется.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "В `spend` сначала обе проверки с `raise`, и только потом `self._points -= points`.\n",
    "\n",
    "</details>"
   ],
   "id": "level-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class LoyaltyCard:\n",
    "    # скопируйте сюда __init__, points, total и earn из шага 1\n",
    "\n",
    "    @property\n",
    "    def level(self):\n",
    "        ...\n",
    "\n",
    "    def spend(self, points):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "level"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def _card(total):\n",
    "    \"карта с покупкой на total ₽ (если total > 0)\"\n",
    "    try:\n",
    "        card = LoyaltyCard(\"Анна\")\n",
    "    except TypeError:\n",
    "        assert False, \"LoyaltyCard(\\\"Анна\\\") не создаётся — скопируйте __init__ из шага 1\"\n",
    "    for name in [\"earn\", \"points\", \"total\"]:\n",
    "        assert hasattr(LoyaltyCard, name), f\"в классе нет {name} — скопируйте класс из шага 1 целиком\"\n",
    "    if total:\n",
    "        card.earn(total)\n",
    "    assert card.total == total and card.points == total * 5 // 100, \"earn, points или total работают не так, как в шаге 1 — в скопированном классе ошибка из шага 1\"\n",
    "    return card\n",
    "\n",
    "\n",
    "def test_level():\n",
    "    \"уровни по сумме покупок\"\n",
    "    assert isinstance(LoyaltyCard.__dict__.get(\"level\"), property), \"level должен быть свойством — с @property\"\n",
    "    for total, expected in [(0, \"базовый\"), (4999, \"базовый\"), (5000, \"серебряный\"), (19999, \"серебряный\"), (20000, \"золотой\")]:\n",
    "        got = _card(total).level\n",
    "        assert got == expected, f\"при покупках на {total} ₽ уровень {got!r}, а нужно {expected!r}\"\n",
    "\n",
    "\n",
    "def test_spend():\n",
    "    \"spend списывает баллы\"\n",
    "    card = _card(4000)\n",
    "    got = card.spend(150)\n",
    "    assert card.points == 50, f\"было 200 баллов, списали 150 — осталось {card.points}, а нужно 50\"\n",
    "\n",
    "\n",
    "def _error(card, points):\n",
    "    try:\n",
    "        card.spend(points)\n",
    "    except ValueError as e:\n",
    "        return str(e)\n",
    "    return None\n",
    "\n",
    "\n",
    "def test_spend_too_much():\n",
    "    \"больше, чем есть, — ValueError «недостаточно баллов»\"\n",
    "    card = _card(4000)\n",
    "    text = _error(card, 500)\n",
    "    assert text is not None, \"spend(500) при 200 баллах не выбросил ValueError\"\n",
    "    assert text == \"недостаточно баллов\", f\"сообщение {text!r}, а нужно \\\"недостаточно баллов\\\"\"\n",
    "    assert card.points == 200, f\"после неудачного списания баллов {card.points} — баланс не должен меняться\"\n",
    "\n",
    "\n",
    "def test_spend_not_positive():\n",
    "    \"0 и меньше — ValueError\"\n",
    "    card = _card(4000)\n",
    "    for bad in [0, -50]:\n",
    "        text = _error(card, bad)\n",
    "        assert text is not None, f\"spend({bad}) не выбросил ValueError\"\n",
    "        assert text == \"списать можно только положительное число баллов\", f\"для spend({bad}) сообщение {text!r}\"\n",
    "        assert card.points == 200, f\"после spend({bad}) баллов {card.points} — баланс не должен меняться\"\n",
    "\n",
    "_check(test_level, test_spend, test_spend_too_much, test_spend_not_positive, names=[\"LoyaltyCard.level\",\"LoyaltyCard.spend\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "level-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class LoyaltyCard:\n",
    "    def __init__(self, owner):\n",
    "        self.owner = owner\n",
    "        self._points = 0\n",
    "        self._total = 0\n",
    "\n",
    "    @property\n",
    "    def points(self):\n",
    "        return self._points\n",
    "\n",
    "    @property\n",
    "    def total(self):\n",
    "        return self._total\n",
    "\n",
    "    @property\n",
    "    def level(self):\n",
    "        if self._total < 5000:\n",
    "            return \"базовый\"\n",
    "        if self._total < 20000:\n",
    "            return \"серебряный\"\n",
    "        return \"золотой\"\n",
    "\n",
    "    def earn(self, amount):\n",
    "        if amount <= 0:\n",
    "            raise ValueError(\"сумма покупки должна быть больше нуля\")\n",
    "        self._total += amount\n",
    "        earned = amount * 5 // 100\n",
    "        self._points += earned\n",
    "        return earned\n",
    "\n",
    "    def spend(self, points):\n",
    "        if points <= 0:\n",
    "            raise ValueError(\"списать можно только положительное число баллов\")\n",
    "        if points > self._points:\n",
    "            raise ValueError(\"недостаточно баллов\")\n",
    "        self._points -= points\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "level-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Покупки Анны"
   ],
   "id": "text-46"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Начисления за месяц\n",
    "\n",
    "Анна сделала за месяц четыре покупки — суммы в списке `purchases`. Заведите ей карту `anna`, зарегистрируйте\n",
    "покупки по порядку и сохраните в `earned` список начисленных за каждую баллов. В `anna_points` сохраните её\n",
    "баллы, в `anna_level` — уровень карты.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`earned = [anna.earn(x) for x in purchases]` — метод и регистрирует покупку, и возвращает баллы.\n",
    "\n",
    "</details>"
   ],
   "id": "history-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "purchases = [1200, 3500, 800, 900]\n",
    "anna = ...\n",
    "earned = ...\n",
    "anna_points = ...\n",
    "anna_level = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "history"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_card():\n",
    "    \"anna — карта Анны с её покупками\"\n",
    "    assert isinstance(anna, LoyaltyCard), f\"anna — это {type(anna).__name__}, а нужна карта: LoyaltyCard(\\\"Анна\\\")\"\n",
    "    assert anna.owner == \"Анна\", f\"владелец карты — {anna.owner!r}, а нужно \\\"Анна\\\"\"\n",
    "    assert anna.total == 6400, f\"сумма покупок на карте {anna.total}, а нужно 6400 — зарегистрируйте каждую покупку один раз\"\n",
    "\n",
    "\n",
    "def test_earned():\n",
    "    \"earned — баллы за каждую покупку\"\n",
    "    assert isinstance(earned, list), f\"earned — это {type(earned).__name__}, а нужен список\"\n",
    "    assert earned == [60, 175, 40, 45], f\"earned = {earned}, а нужно [60, 175, 40, 45]\"\n",
    "\n",
    "\n",
    "def test_summary():\n",
    "    \"anna_points и anna_level\"\n",
    "    assert anna_points == 320, f\"anna_points = {anna_points!r}, а у Анны 320 баллов\"\n",
    "    assert anna_level == \"серебряный\", f\"anna_level = {anna_level!r}, а при покупках на 6400 ₽ уровень серебряный\"\n",
    "\n",
    "_check(test_card, test_earned, test_summary, names=[\"anna\",\"earned\",\"anna_points\",\"anna_level\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "history-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "purchases = [1200, 3500, 800, 900]\n",
    "anna = LoyaltyCard(\"Анна\")\n",
    "earned = [anna.earn(x) for x in purchases]\n",
    "anna_points = anna.points\n",
    "anna_level = anna.level\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "history-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Оплата баллами"
   ],
   "id": "text-56"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Попытка списать лишнее\n",
    "\n",
    "Анна хочет оплатить кофе 1000 баллами — у неё столько нет. Попробуйте списать 1000 баллов, перехватите\n",
    "`ValueError` и сохраните текст сообщения в `error_text`. Затем спишите 100 баллов и сохраните остаток\n",
    "в `left`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`except ValueError as error:` — и `error_text = str(error)`.\n",
    "\n",
    "</details>"
   ],
   "id": "pay-points-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "error_text = ...\n",
    "left = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "pay-points"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_error():\n",
    "    \"error_text — сообщение об ошибке\"\n",
    "    assert isinstance(error_text, str), f\"error_text — это {type(error_text).__name__}, а нужен текст ошибки: str(error)\"\n",
    "    assert error_text == \"недостаточно баллов\", f\"error_text = {error_text!r}, а spend(1000) должен сообщить «недостаточно баллов»\"\n",
    "\n",
    "\n",
    "def test_left():\n",
    "    \"left — остаток после списания 100\"\n",
    "    if left < 220 and (220 - left) % 100 == 0:\n",
    "        assert False, f\"left = {left}, а нужно 220: списано больше 100 баллов. Если ячейка выполнялась несколько раз, списание повторилось — нажмите «Выполнить все выше» и выполните её один раз; иначе проверьте, что списываете ровно 100\"\n",
    "    assert left != 320, \"остаток не изменился — после неудачной попытки спишите 100 баллов: anna.spend(100)\"\n",
    "    assert left == 220, f\"left = {left!r}, а после списания 100 из 320 баллов осталось 220\"\n",
    "\n",
    "_check(test_error, test_left, names=[\"error_text\",\"left\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "pay-points-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "try:\n",
    "    anna.spend(1000)\n",
    "except ValueError as error:\n",
    "    error_text = str(error)\n",
    "anna.spend(100)\n",
    "left = anna.points\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "pay-points-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Баланс не перепишешь\n",
    "try:\n",
    "    anna.points = 1_000_000\n",
    "except AttributeError as error:\n",
    "    print(\"не вышло:\", error)\n",
    "print(anna.points)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "guard"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 5. Золотые клиенты"
   ],
   "id": "text-68"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. Кому золотую карту\n",
    "\n",
    "В словаре `clients` — имена и суммы покупок каждого клиента за год. Заведите по карте на клиента,\n",
    "зарегистрируйте все его покупки и сохраните в `cards` словарь «имя → карта». В `gold` сохраните список имён\n",
    "клиентов с золотым уровнем — в порядке словаря.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Цикл `for name, amounts in clients.items():` — создать карту, в цикле по суммам вызвать `earn`, положить карту в словарь.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`gold = [name for name, card in cards.items() if card.level == \"золотой\"]`.\n",
    "\n",
    "</details>"
   ],
   "id": "gold-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "clients = {\n",
    "    \"Анна\": [1200, 3500, 800, 900],\n",
    "    \"Борис\": [15000, 7000],\n",
    "    \"Вера\": [300, 450],\n",
    "    \"Глеб\": [9000, 9000, 2500],\n",
    "}\n",
    "cards = ...\n",
    "gold = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "gold"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "def test_cards():\n",
    "    \"cards — карта на каждого клиента\"\n",
    "    assert isinstance(cards, dict), f\"cards — это {type(cards).__name__}, а нужен словарь «имя → карта»\"\n",
    "    assert list(cards) == [\"Анна\", \"Борис\", \"Вера\", \"Глеб\"], f\"ключи cards: {list(cards)}\"\n",
    "    assert all(isinstance(c, LoyaltyCard) for c in cards.values()), \"значения cards — карты LoyaltyCard\"\n",
    "    totals = {name: card.total for name, card in cards.items()}\n",
    "    assert totals == {\"Анна\": 6400, \"Борис\": 22000, \"Вера\": 750, \"Глеб\": 20500}, f\"суммы покупок на картах: {totals} — зарегистрируйте все покупки каждого клиента\"\n",
    "    assert cards[\"Анна\"] is not cards[\"Борис\"], \"у клиентов должны быть разные карты\"\n",
    "\n",
    "\n",
    "def test_gold():\n",
    "    \"gold — золотые клиенты\"\n",
    "    assert gold == [\"Борис\", \"Глеб\"], f\"gold = {gold}, а золотой уровень — у Бориса и Глеба\"\n",
    "\n",
    "_check(test_cards, test_gold, names=[\"cards\",\"gold\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "gold-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "clients = {\n",
    "    \"Анна\": [1200, 3500, 800, 900],\n",
    "    \"Борис\": [15000, 7000],\n",
    "    \"Вера\": [300, 450],\n",
    "    \"Глеб\": [9000, 9000, 2500],\n",
    "}\n",
    "cards = {}\n",
    "for name, amounts in clients.items():\n",
    "    card = LoyaltyCard(name)\n",
    "    for amount in amounts:\n",
    "        card.earn(amount)\n",
    "    cards[name] = card\n",
    "gold = [name for name, card in cards.items() if card.level == \"золотой\"]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "gold-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог"
   ],
   "id": "text-79"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Карты клиентов\n",
    "for name, card in cards.items():\n",
    "    print(f\"{name}: {card.total} ₽, {card.points} баллов, уровень {card.level}\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "У Бориса и Глеба — золотые карты: за год они потратили больше 20 000 ₽. Анна — на серебряном уровне,\n",
    "Вера — на базовом. И через интерфейс карты баланс не изменить мимо `earn` и `spend`: запись в `points` даёт\n",
    "`AttributeError`, а неверные суммы — `ValueError` с понятным сообщением."
   ],
   "id": "component-84"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Посмотрите на класс со стороны: снаружи видны только `owner`, `points`, `total`, `level`, `earn` и `spend`.\n",
    "Как карта хранит данные внутри — её личное дело; можно, например, начать хранить историю покупок списком,\n",
    "и код снаружи этого не заметит. В следующем модуле объекты научатся красиво печататься и сравниваться."
   ],
   "id": "text-89"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
