{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: скидки\n",
    "\n",
    "Иерархия скидок: базовый Discount и подклассы PercentDiscount, FixedDiscount и BuyNGetOne. Касса применяет любую скидку одним вызовом и выбирает самую выгодную.\n",
    "\n",
    "Курс ООП в Python · урок `oop-p5-discounts` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/p5-discounts).\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": [
    "У «Кофейной лавки» три вида акций: процент от суммы, фиксированная сумма и «каждый n-й напиток в подарок».\n",
    "Касса должна уметь применить любую из них — и выбрать для покупателя самую выгодную. Если писать это\n",
    "цепочкой `if`, каждая новая акция будет требовать правки кассы. Вместо этого сделаем иерархию классов:\n",
    "у всех скидок один метод `amount(cart)` — сколько рублей скидка даёт для корзины, — а касса просто вызывает его.\n",
    "\n",
    "## Что у нас уже есть\n",
    "\n",
    "Товар и корзина из прошлых модулей: `subtotal` — сумма корзины без скидок."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Товар и корзина\n",
    "class Product:\n",
    "    def __init__(self, name, price):\n",
    "        self.name = name\n",
    "        self.price = price\n",
    "\n",
    "    def __repr__(self):\n",
    "        return f\"Product({self.name!r}, {self.price})\"\n",
    "\n",
    "\n",
    "class Cart:\n",
    "    def __init__(self, items=None):\n",
    "        self.items = list(items) if items is not None else []\n",
    "\n",
    "    def add(self, product):\n",
    "        self.items.append(product)\n",
    "\n",
    "    @property\n",
    "    def subtotal(self):\n",
    "        return sum(p.price for p in self.items)\n",
    "\n",
    "\n",
    "cart = Cart([Product(\"Латте\", 220), Product(\"Круассан\", 140)])\n",
    "print(cart.items, cart.subtotal)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "cart"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 1. Базовая скидка"
   ],
   "id": "text-21"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Класс Discount\n",
    "\n",
    "Допишите класс `Discount`: `Discount(\"без скидки\")` хранит название акции в атрибуте `title`, `str(discount)`\n",
    "возвращает это название, а метод `amount(cart)` возвращает 0 — базовая «скидка» ничего не скидывает.\n",
    "Подклассы переопределят `amount`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`__str__` возвращает `self.title`; `amount` — `return 0`.\n",
    "\n",
    "</details>"
   ],
   "id": "base-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Discount:\n",
    "    def __init__(self, title):\n",
    "        ...\n",
    "\n",
    "    def __str__(self):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "base"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_title():\n",
    "    \"title и str\"\n",
    "    d = Discount(\"без скидки\")\n",
    "    assert getattr(d, \"title\", None) == \"без скидки\", \"название хранится в атрибуте title\"\n",
    "    assert \"__str__\" in Discount.__dict__, \"в классе нет __str__\"\n",
    "    assert str(d) == \"без скидки\", f\"str(Discount(\\\"без скидки\\\")) = {str(d)!r}\"\n",
    "\n",
    "\n",
    "def test_amount():\n",
    "    \"базовая скидка — 0\"\n",
    "    got = Discount(\"без скидки\").amount(Cart([Product(\"Латте\", 220)]))\n",
    "    assert got == 0, f\"amount у базовой скидки = {got!r}, а нужно 0\"\n",
    "\n",
    "_check(test_title, test_amount, names=[\"Discount.__init__\",\"Discount.__str__\",\"Discount.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "base-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Discount:\n",
    "    def __init__(self, title):\n",
    "        self.title = title\n",
    "\n",
    "    def __str__(self):\n",
    "        return self.title\n",
    "\n",
    "    def amount(self, cart):\n",
    "        return 0\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "base-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Процент и фиксированная сумма\n",
    "\n",
    "Подклассы наследуют от `Discount` всё, что есть в шаге 1, и переопределяют только `amount`. Название акции\n",
    "каждый подкласс собирает сам и передаёт в `__init__` базового класса."
   ],
   "id": "text-31"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Процент от суммы\n",
    "\n",
    "Объявите подкласс `PercentDiscount`: `PercentDiscount(10)` — скидка 10 %, процент хранится в атрибуте\n",
    "`percent`, название — `\"скидка 10 %\"`. `amount(cart)` — процент от `cart.subtotal`, округлённый до целых.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`super().__init__(f\"скидка {percent} %\")`, затем `self.percent = percent`.\n",
    "\n",
    "</details>"
   ],
   "id": "percent-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class PercentDiscount(Discount):\n",
    "    def __init__(self, percent):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "percent"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_init():\n",
    "    \"percent и название\"\n",
    "    d = PercentDiscount(10)\n",
    "    assert issubclass(PercentDiscount, Discount), \"PercentDiscount должен наследовать от Discount\"\n",
    "    assert getattr(d, \"percent\", None) == 10, \"процент хранится в атрибуте percent\"\n",
    "    assert getattr(d, \"title\", None) == \"скидка 10 %\", \"название передайте в __init__ базового класса: super().__init__(f\\\"скидка {percent} %\\\")\"\n",
    "    assert str(d) == \"скидка 10 %\", f\"str(PercentDiscount(10)) = {str(d)!r} — __str__ наследуется от Discount\"\n",
    "\n",
    "\n",
    "def test_amount():\n",
    "    \"процент от суммы, округлённый\"\n",
    "    cart = Cart([Product(\"Латте\", 220), Product(\"Круассан\", 140), Product(\"Чай\", 120)])\n",
    "    got = PercentDiscount(10).amount(cart)\n",
    "    assert got != 432, \"это сумма после скидки, а amount — размер самой скидки\"\n",
    "    assert got == 48, f\"10 % от 480 = {got!r}, а нужно 48\"\n",
    "    got = PercentDiscount(15).amount(Cart([Product(\"Торт\", 1063)]))\n",
    "    assert got == 159, f\"15 % от 1063 = {got!r}, а нужно 159 (159.45, округлите)\"\n",
    "\n",
    "_check(test_init, test_amount, names=[\"PercentDiscount.__init__\",\"PercentDiscount.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "percent-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class PercentDiscount(Discount):\n",
    "    def __init__(self, percent):\n",
    "        super().__init__(f\"скидка {percent} %\")\n",
    "        self.percent = percent\n",
    "\n",
    "    def amount(self, cart):\n",
    "        return round(cart.subtotal * self.percent / 100)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "percent-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Фиксированная сумма\n",
    "\n",
    "Объявите подкласс `FixedDiscount`: `FixedDiscount(150)` — минус 150 ₽, сумма в атрибуте `value`, название —\n",
    "`\"минус 150 ₽\"`. Скидка не может быть больше суммы корзины: для корзины на 100 ₽ `amount` — 100.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Меньшее из двух чисел — `min(self.value, cart.subtotal)`.\n",
    "\n",
    "</details>"
   ],
   "id": "fixed-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class FixedDiscount(Discount):\n",
    "    def __init__(self, value):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fixed"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_init():\n",
    "    \"value и название\"\n",
    "    d = FixedDiscount(150)\n",
    "    assert issubclass(FixedDiscount, Discount), \"FixedDiscount должен наследовать от Discount\"\n",
    "    assert hasattr(d, \"title\"), \"у скидки нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    assert getattr(d, \"value\", None) == 150, \"сумма хранится в атрибуте value\"\n",
    "    assert str(d) == \"минус 150 ₽\", f\"str(FixedDiscount(150)) = {str(d)!r}, а нужно \\\"минус 150 ₽\\\"\"\n",
    "\n",
    "\n",
    "def test_amount():\n",
    "    \"фиксированная сумма, но не больше корзины\"\n",
    "    assert FixedDiscount(150).amount(Cart([Product(\"Латте\", 220), Product(\"Чай\", 120)])) == 150, \"для корзины на 340 ₽ скидка — 150\"\n",
    "    got = FixedDiscount(150).amount(Cart([Product(\"Печенье\", 90)]))\n",
    "    assert got != 150, \"скидка больше суммы корзины: для корзины на 90 ₽ она должна быть 90\"\n",
    "    assert got == 90, f\"для корзины на 90 ₽ amount = {got!r}, а нужно 90\"\n",
    "\n",
    "_check(test_init, test_amount, names=[\"FixedDiscount.__init__\",\"FixedDiscount.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fixed-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class FixedDiscount(Discount):\n",
    "    def __init__(self, value):\n",
    "        super().__init__(f\"минус {value} ₽\")\n",
    "        self.value = value\n",
    "\n",
    "    def amount(self, cart):\n",
    "        return min(self.value, cart.subtotal)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "fixed-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Каждый n-й в подарок"
   ],
   "id": "text-50"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Каждый n-й бесплатно\n",
    "\n",
    "Объявите подкласс `BuyNGetOne`: `BuyNGetOne(\"Латте\", 3)` — каждый третий латте в подарок, название —\n",
    "`\"каждый 3-й Латте в подарок\"`, атрибуты `name` и `n`. `amount(cart)`: сколько товаров с таким названием\n",
    "в корзине, столько раз по `n` целиком — столько бесплатных; скидка — их число, умноженное на цену этого\n",
    "товара. Нет товара в корзине — 0.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Товары с нужным названием — `[p for p in cart.items if p.name == self.name]`; бесплатных — `len(...) // self.n`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Цену возьмите у любого из найденных товаров, например у первого, — но только если список не пуст.\n",
    "\n",
    "</details>"
   ],
   "id": "buy-n-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class BuyNGetOne(Discount):\n",
    "    def __init__(self, name, n):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "buy-n"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def _latte_cart(k):\n",
    "    return Cart([Product(\"Латте\", 220) for _ in range(k)] + [Product(\"Чай\", 120)])\n",
    "\n",
    "\n",
    "def test_init():\n",
    "    \"name, n и название\"\n",
    "    d = BuyNGetOne(\"Латте\", 3)\n",
    "    assert issubclass(BuyNGetOne, Discount), \"BuyNGetOne должен наследовать от Discount\"\n",
    "    assert hasattr(d, \"title\"), \"у скидки нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    assert (getattr(d, \"name\", None), getattr(d, \"n\", None)) == (\"Латте\", 3), \"название товара и n хранятся в атрибутах name и n\"\n",
    "    assert str(d) == \"каждый 3-й Латте в подарок\", f\"str(BuyNGetOne(\\\"Латте\\\", 3)) = {str(d)!r}\"\n",
    "\n",
    "\n",
    "def test_amount():\n",
    "    \"по одному бесплатному на каждые n\"\n",
    "    d = BuyNGetOne(\"Латте\", 3)\n",
    "    assert d.amount(_latte_cart(2)) == 0, \"из двух латте бесплатных нет — скидка 0\"\n",
    "    assert d.amount(_latte_cart(3)) == 220, f\"из трёх латте один бесплатный — скидка 220, а получилось {d.amount(_latte_cart(3))!r}\"\n",
    "    got = d.amount(_latte_cart(7))\n",
    "    assert got != 7 // 3, \"получилось число бесплатных, а нужна скидка в рублях — умножьте на цену\"\n",
    "    assert got == 440, f\"из семи латте бесплатных два — скидка 440, а получилось {got!r}\"\n",
    "\n",
    "\n",
    "def test_absent():\n",
    "    \"товара нет — скидка 0\"\n",
    "    got = BuyNGetOne(\"Раф\", 2).amount(_latte_cart(3))\n",
    "    assert got == 0, f\"рафа в корзине нет, а скидка {got!r}\"\n",
    "    assert BuyNGetOne(\"Раф\", 2).amount(Cart()) == 0, \"у пустой корзины скидка 0\"\n",
    "\n",
    "_check(test_init, test_amount, test_absent, names=[\"BuyNGetOne.__init__\",\"BuyNGetOne.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "buy-n-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class BuyNGetOne(Discount):\n",
    "    def __init__(self, name, n):\n",
    "        super().__init__(f\"каждый {n}-й {name} в подарок\")\n",
    "        self.name = name\n",
    "        self.n = n\n",
    "\n",
    "    def amount(self, cart):\n",
    "        same = [p for p in cart.items if p.name == self.name]\n",
    "        if not same:\n",
    "            return 0\n",
    "        return len(same) // self.n * same[0].price\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "buy-n-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Касса"
   ],
   "id": "text-62"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. К оплате и лучшая акция\n",
    "\n",
    "Напишите две функции. `to_pay(cart, discount)` — сумма к оплате: `subtotal` корзины минус скидка.\n",
    "`best_discount(cart, discounts)` — та скидка из списка, которая для этой корзины даёт больше всего рублей.\n",
    "Ни одна функция не должна проверять класс скидки.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Обе функции вызывают только `discount.amount(cart)` — это и есть полиморфизм.\n",
    "\n",
    "</details>"
   ],
   "id": "checkout-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "def to_pay(cart, discount):\n",
    "    ...\n",
    "\n",
    "\n",
    "def best_discount(cart, discounts):\n",
    "    ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "checkout"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "class _Coupon:\n",
    "    \"скидка, о которой касса заранее не знает\"\n",
    "\n",
    "    def amount(self, cart):\n",
    "        return 70\n",
    "\n",
    "\n",
    "def test_to_pay():\n",
    "    \"к оплате — сумма минус скидка\"\n",
    "    cart = Cart([Product(\"Латте\", 220), Product(\"Чай\", 120)])\n",
    "    got = to_pay(cart, FixedDiscount(100))\n",
    "    assert got is not None, \"to_pay ничего не возвращает — нужен return\"\n",
    "    assert got == 240, f\"to_pay для корзины 340 ₽ и скидки 100 ₽ = {got!r}, а нужно 240\"\n",
    "    assert to_pay(cart, _Coupon()) == 270, \"to_pay должна работать с любой скидкой, у которой есть amount(cart)\"\n",
    "\n",
    "\n",
    "def test_best():\n",
    "    \"лучшая — с наибольшей скидкой\"\n",
    "    cart = Cart([Product(\"Латте\", 220), Product(\"Чай\", 120)])\n",
    "    percent, fixed, coupon = PercentDiscount(10), FixedDiscount(50), _Coupon()\n",
    "    got = best_discount(cart, [percent, fixed, coupon])\n",
    "    assert not isinstance(got, (int, float)), f\"best_discount вернула число {got!r}, а нужна сама скидка\"\n",
    "    assert got is coupon, \"из скидок 34, 50 и 70 ₽ лучшая — 70 ₽: сравнивайте amount(cart) любых скидок, без проверок класса\"\n",
    "\n",
    "_check(test_to_pay, test_best)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "checkout-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "def to_pay(cart, discount):\n",
    "    return cart.subtotal - discount.amount(cart)\n",
    "\n",
    "\n",
    "def best_discount(cart, discounts):\n",
    "    return max(discounts, key=lambda d: d.amount(cart))\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "checkout-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 5. Корзина Анны"
   ],
   "id": "text-72"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 6. Что выгоднее Анне\n",
    "\n",
    "В корзине Анны три латте, два круассана и чай. Для трёх акций из списка `offers` сохраните в `prices` словарь\n",
    "«название акции → сумма к оплате», а в `best` — название самой выгодной акции.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Название акции — `str(discount)`; сумма — `to_pay(anna, discount)`.\n",
    "\n",
    "</details>"
   ],
   "id": "anna-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "anna = Cart([Product(\"Латте\", 220)] * 3 + [Product(\"Круассан\", 140)] * 2 + [Product(\"Чай\", 120)])\n",
    "offers = [PercentDiscount(10), FixedDiscount(150), BuyNGetOne(\"Латте\", 3)]\n",
    "prices = ...\n",
    "best = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "anna"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 6 — выполните после своего решения\n",
    "def test_prices():\n",
    "    \"prices — к оплате по каждой акции\"\n",
    "    assert isinstance(prices, dict), f\"prices — это {type(prices).__name__}, а нужен словарь\"\n",
    "    assert set(prices) == {\"скидка 10 %\", \"минус 150 ₽\", \"каждый 3-й Латте в подарок\"}, f\"ключи prices: {list(prices)} — используйте str(акция)\"\n",
    "    assert prices[\"скидка 10 %\"] != 106, \"в словаре размер скидки, а нужна сумма к оплате: to_pay(anna, акция)\"\n",
    "    assert prices == {\"скидка 10 %\": 954, \"минус 150 ₽\": 910, \"каждый 3-й Латте в подарок\": 840}, f\"prices = {prices}\"\n",
    "\n",
    "\n",
    "def test_best():\n",
    "    \"best — название лучшей акции\"\n",
    "    assert isinstance(best, str), f\"best — это {type(best).__name__}, а нужно название акции строкой: str(...)\"\n",
    "    assert best == \"каждый 3-й Латте в подарок\", f\"best = {best!r}, а выгоднее всего третий латте в подарок\"\n",
    "\n",
    "_check(test_prices, test_best, names=[\"prices\",\"best\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "anna-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "anna = Cart([Product(\"Латте\", 220)] * 3 + [Product(\"Круассан\", 140)] * 2 + [Product(\"Чай\", 120)])\n",
    "offers = [PercentDiscount(10), FixedDiscount(150), BuyNGetOne(\"Латте\", 3)]\n",
    "prices = {str(d): to_pay(anna, d) for d in offers}\n",
    "best = str(best_discount(anna, offers))\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "anna-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог"
   ],
   "id": "text-81"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Акции для корзины Анны\n",
    "print(f\"корзина Анны без скидки: {anna.subtotal} ₽\")\n",
    "for offer in offers:\n",
    "    print(f\"{offer}: скидка {offer.amount(anna)} ₽, к оплате {to_pay(anna, offer)} ₽\")\n",
    "print(\"лучшая акция:\", best)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Без скидки корзина Анны стоит 1060 ₽. Скидка 10 % даёт 106 ₽, «минус 150 ₽» — 150 ₽, а третий латте\n",
    "в подарок — 220 ₽: эта акция выгоднее всех, Анна платит 840 ₽."
   ],
   "id": "component-86"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Посмотрите на функции кассы: в них нет ни одного `isinstance`. Новая акция — скажем, «скидка на выпечку» —\n",
    "это новый подкласс с методом `amount`, а `to_pay` и `best_discount` не меняются. Но базовый `Discount` пока\n",
    "можно создать и «забыть» переопределить в подклассе `amount` — касса молча посчитает скидку 0. Как запретить\n",
    "такое — в следующем модуле."
   ],
   "id": "text-90"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
