diff --git a/app/handwritten/lessons_oop.py b/app/handwritten/lessons_oop.py new file mode 100644 index 0000000..15330a3 --- /dev/null +++ b/app/handwritten/lessons_oop.py @@ -0,0 +1,1136 @@ +"""Рукописные уроки 65–72: проектирование и расширение классов.""" + +from __future__ import annotations + +from typing import Any + + +def _card(title: str, text: str, example: str, tip: str = "") -> dict[str, str]: + return {"title": title, "text": text, "example": example, "tip": tip} + + +def _choice( + lesson_id: str, + prompt: str, + options: list[str], + answer: str, + explanation: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-choice", + "kind": "choice", + "prompt": prompt, + "options": options, + "answer": answer, + "explanation": explanation, + } + + +def _term( + lesson_id: str, + prompt: str, + answers: list[str], + explanation: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-term", + "kind": "input", + "prompt": prompt, + "answers": answers, + "placeholder": "Введите ответ", + "explanation": explanation, + } + + +def _code( + lesson_id: str, + prompt: str, + starter: str, + tests: list[dict[str, Any]], + reference: str, + explanation: str, + hint: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-code", + "kind": "code", + "prompt": prompt, + "starter": starter, + "tests": tests, + "reference": reference, + "explanation": explanation, + "hint": hint, + } + + +LESSONS: list[dict[str, Any]] = [ + { + "id": "oop-design-init", + "theory": [ + _card( + "Что такое конструктор и зачем он нужен", + "Конструктор — это метод `__init__`, который Python вызывает сам в момент " + "создания объекта. В нём ты задаёшь начальное состояние: какие данные объект " + "получает при рождении. Без конструктора пришлось бы создавать пустой объект " + "и вручную добавлять каждое поле. С конструктором объект сразу готов к работе.", + """class Task: + def __init__(self, title): + self.title = title + +task = Task('Купить хлеб') +print(task.title) +# Вывод: +# Купить хлеб""", + "`__init__` не создаёт объект, а наполняет его данными.", + ), + _card( + "Как пользоваться: сохрани каждый аргумент", + "Внутри `__init__` аргументы превращаются в атрибуты через `self`. Пиши " + "`self.x = x`, тогда значение останется в объекте и после выхода из метода. " + "`self` — это ссылка на конкретный экземпляр, поэтому два объекта хранят свои " + "значения независимо. В примере две точки помнят разные координаты.", + """class Point: + def __init__(self, x, y): + self.x = x + self.y = y + +first = Point(1, 2) +second = Point(10, 20) +print(first.x + second.y) +# Вывод: +# 21""", + "Передавай в конструктор ровно те данные, без которых объект не имеет смысла.", + ), + _card( + "Типичная ошибка: присвоить значение не в self", + "Если написать `title = title` вместо `self.title = title`, значение останется " + "в локальной переменной и исчезнет после выхода из конструктора. Ошибка не " + "падает: объект создаётся, только нужного атрибута в нём нет. Заметь её через " + "`hasattr`: если поле не находится, проверь, есть ли перед именем `self`.", + """class Task: + def __init__(self, title): + title = title + +task = Task('Отчёт') +print(hasattr(task, 'title')) +# Вывод: +# False""", + "Атрибут появляется только при присваивании в `self.имя`.", + ), + ], + "questions": [ + _choice( + "oop-design-init", + "Что выведет код?\n\n" + "class Point:\n" + " def __init__(self, x, y):\n" + " self.x = x\n" + " self.y = y\n" + "\n" + "point = Point(2, 5)\n" + "print(point.x * point.y)", + ["10", "15", "20", "25"], + "10", + "Конструктор сохранил 2 и 5 в поля, а `point.x * point.y` перемножил их.", + ), + _term( + "oop-design-init", + "Какой метод класса вызывается автоматически при создании объекта?", + ["__init__", "init", "конструктор", "метод __init__"], + "При создании экземпляра Python сам вызывает `__init__` с переданными аргументами.", + ), + _code( + "oop-design-init", + "Создай `class Point` с конструктором `__init__(self, x, y)` и методом " + "`distance_to_origin()`. Метод возвращает расстояние от точки до начала координат.", + """class Point: + def __init__(self, x, y): + pass + + def distance_to_origin(self): + pass +""", + [ + {"kind": "call", "call": "Point(3, 4).distance_to_origin()", "expected": 5.0}, + {"kind": "call", "call": "Point(0, 0).distance_to_origin()", "expected": 0.0}, + {"kind": "call", "call": "Point(0, 0).x", "expected": 0}, + ], + """class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + def distance_to_origin(self): + return (self.x ** 2 + self.y ** 2) ** 0.5 +""", + "Конструктор сохранил координаты в экземпляре, а метод прочитал их через `self`.", + "Сохрани `x` и `y` в `self`, а расстояние посчитай как " + "`(self.x ** 2 + self.y ** 2) ** 0.5`.", + ), + ], + }, + { + "id": "oop-design-repr-str", + "theory": [ + _card( + "Что такое __str__ и зачем он нужен", + "`__str__` возвращает строку для человека: её печатает `print` и показывает " + "большинство интерфейсов. Без него Python печатает служебное представление с " + "адресом объекта, и по нему ничего не понять. Метод должен вернуть именно " + "строку, а не напечатать её. Тогда объект читается как обычное значение.", + """class Money: + def __init__(self, amount, currency): + self.amount = amount + self.currency = currency + + def __str__(self): + return f'{self.amount} {self.currency}' + +print(Money(100, 'RUB')) +# Вывод: +# 100 RUB""", + "`print(obj)` вызывает `__str__`, если он определён.", + ), + _card( + "Как пользоваться: два взгляда на объект", + "`__str__` нужен пользователю, а `__repr__` — разработчику: по нему удобно " + "видеть объект в списке и отладчике. В `__repr__` принято писать выражение, " + "похожее на код создания объекта. `!r` добавляет кавычки вокруг строки, чтобы " + "представление можно было скопировать в код. Проверяй оба метода отдельно " + "через `str()` и `repr()`.", + """class Money: + def __init__(self, amount, currency): + self.amount = amount + self.currency = currency + + def __str__(self): + return f'{self.amount} {self.currency}' + + def __repr__(self): + return f'Money({self.amount}, {self.currency!r})' + +money = Money(5, 'USD') +print(str(money)) +print(repr(money)) +# Вывод: +# 5 USD +# Money(5, 'USD')""", + "Для строкового поля в `__repr__` ставь `!r`.", + ), + _card( + "Типичная ошибка: потерять кавычки в __repr__", + "Если в `__repr__` вывести строку без `!r`, кавычки исчезнут. `Money(5, USD)` " + "выглядит как вызов с неизвестным именем, а не как данные, которые можно " + "скопировать в код. Заметь это по выводу `repr()`: строковые поля должны быть " + "в кавычках. Исправление — `{self.currency!r}`.", + """class Money: + def __init__(self, amount, currency): + self.amount = amount + self.currency = currency + + def __repr__(self): + return f'Money({self.amount}, {self.currency})' + +print(repr(Money(5, 'USD'))) +# Вывод: +# Money(5, USD)""", + "Сравни `repr(obj)` с тем, как ты сам записал бы создание объекта.", + ), + ], + "questions": [ + _choice( + "oop-design-repr-str", + "Что выведет код?\n\n" + "class Money:\n" + " def __init__(self, amount):\n" + " self.amount = amount\n" + "\n" + " def __str__(self):\n" + " return f'{self.amount} руб.'\n" + "\n" + " def __repr__(self):\n" + " return f'Money({self.amount})'\n" + "\n" + "print(repr(Money(7)))", + ["Money(7)", "Money[7]", "Money(7.0)", "Money('7')"], + "Money(7)", + "`repr` собирает `Money(...)` из числа, а `__str__` с рублями здесь не участвует.", + ), + _term( + "oop-design-repr-str", + "Какой метод задаёт представление объекта для разработчика — в отладчике и списках?", + ["__repr__", "repr", "метод __repr__", "__repr__()"], + "`__repr__` показывает объект разработчику, а `__str__` — пользователю.", + ), + _code( + "oop-design-repr-str", + "Создай `class Money` с конструктором `__init__(self, amount, currency)`. " + "`__str__` должен возвращать `'100 RUB'`, а `__repr__` — `\"Money(100, 'RUB')\"`.", + """class Money: + def __init__(self, amount, currency): + pass + + def __str__(self): + pass + + def __repr__(self): + pass +""", + [ + {"kind": "call", "call": "str(Money(100, 'RUB'))", "expected": "100 RUB"}, + { + "kind": "call", + "call": "repr(Money(5, 'USD'))", + "expected": "Money(5, 'USD')", + }, + { + "kind": "call", + "call": "str(Money(0, 'EUR'))", + "expected": "0 EUR", + }, + ], + """class Money: + def __init__(self, amount, currency): + self.amount = amount + self.currency = currency + + def __str__(self): + return f'{self.amount} {self.currency}' + + def __repr__(self): + return f'Money({self.amount}, {self.currency!r})' +""", + "`__str__` показывает сумму и валюту человеку, а `__repr__` добавляет кавычки " + "вокруг валюты через `!r`.", + "В `__repr__` используй `{self.currency!r}`, чтобы вокруг кода валюты " + "появились кавычки.", + ), + ], + }, + { + "id": "oop-design-class-instance", + "theory": [ + _card( + "Что такое атрибут класса и атрибут экземпляра", + "Атрибут класса объявлен прямо в теле класса и общий для всех экземпляров. " + "Атрибут экземпляра создаётся через `self` в методе и принадлежит только " + "этому объекту. В примере `species` один на всех, а `name` у каждой кошки " + "свой. Python сначала ищет имя у экземпляра, а потом у класса.", + """class Cat: + species = 'кошка' + + def __init__(self, name): + self.name = name + +cat = Cat('Мурка') +print(cat.species) +print(cat.name) +# Вывод: +# кошка +# Мурка""", + "Общее для всех — в классе, личное — в экземпляре.", + ), + _card( + "Как пользоваться: общее поведение и личные данные", + "Классовое поле удобно для того, что действительно одинаково: вид животного, " + "валюта по умолчанию, версия формата. Личные данные передавай в конструктор и " + "сохраняй через `self`. Тогда метод может соединить общее и личное в одной " + "строке. Если поменять поле у одного экземпляра, остальные сохранят классовое " + "значение.", + """class Cat: + species = 'кошка' + + def __init__(self, name): + self.name = name + + def introduce(self): + return f'{self.name}, {self.species}' + +print(Cat('Барсик').introduce()) +# Вывод: +# Барсик, кошка""", + "Метод читает оба вида атрибутов через `self` одинаково.", + ), + _card( + "Типичная ошибка: изменяемый атрибут класса", + "Список в теле класса создаётся один раз и делится между всеми экземплярами. " + "Если одна кошка добавит игрушку, она появится и у другой. Ошибка тихая: код " + "работает, но данные протекают между объектами. Заметь её по неожиданно общему " + "списку; личные списки создавай в `__init__` через `self.toys = []`.", + """class Cat: + toys = [] + + def __init__(self, name): + self.name = name + +cat_a = Cat('Мурка') +cat_b = Cat('Барсик') +cat_a.toys.append('мяч') +print(cat_b.toys) +# Вывод: +# ['мяч']""", + "Изменяемые данные всегда создавай внутри `__init__`.", + ), + ], + "questions": [ + _choice( + "oop-design-class-instance", + "Что выведет код?\n\n" + "class Cat:\n" + " species = 'кошка'\n" + "\n" + " def __init__(self, name):\n" + " self.name = name\n" + "\n" + "cat = Cat('Мурка')\n" + "cat.species = 'тигр'\n" + "print(Cat('Барсик').species)", + ["кошка", "тигр", "Мурка", "Барсик"], + "кошка", + "Присваивание создало атрибут только у `cat`, а классовое поле осталось прежним.", + ), + _term( + "oop-design-class-instance", + "Как называется атрибут, заданный в теле класса и общий для всех экземпляров?", + [ + "атрибут класса", + "class attribute", + "классовый атрибут", + "атрибут класса (class attribute)", + ], + "Поле в теле класса принадлежит классу, поэтому его видят все экземпляры.", + ), + _code( + "oop-design-class-instance", + "Создай `class Cat` с атрибутом класса `species = 'кошка'` и конструктором " + "`__init__(self, name)`, который сохраняет имя экземпляра.", + """class Cat: + species = 'кошка' + + def __init__(self, name): + pass +""", + [ + {"kind": "call", "call": "Cat('Мурка').species", "expected": "кошка"}, + {"kind": "call", "call": "Cat('Барсик').name", "expected": "Барсик"}, + {"kind": "call", "call": "Cat('Барсик').species", "expected": "кошка"}, + ], + """class Cat: + species = 'кошка' + + def __init__(self, name): + self.name = name +""", + "`species` объявлен в теле класса и общий, а `name` сохранён в экземпляре.", + "Оставь `species` на уровне класса, а в `__init__` присвой `self.name = name`.", + ), + ], + }, + { + "id": "oop-design-composition", + "theory": [ + _card( + "Что такое композиция", + "Композиция — это когда один объект хранит другой как свою часть. Машина не " + "является двигателем, она содержит двигатель. Внутри `Car` лежит готовый " + "`Engine`, и машина пользуется его данными через `self.engine`. Так части " + "можно менять и проверять отдельно друг от друга.", + """class Engine: + def __init__(self, power): + self.power = power + +class Car: + def __init__(self, engine): + self.engine = engine + +car = Car(Engine(150)) +print(car.engine.power) +# Вывод: +# 150""", + "Композиция отвечает на вопрос «из чего состоит объект».", + ), + _card( + "Как пользоваться: собери объект из готовых частей", + "Создавай части снаружи и передавай их в конструктор. Тогда `Car` не знает, " + "как собирать двигатель, и не дублирует его логику. Метод `describe` читает " + "`self.engine.power` и добавляет своё оформление. Если завтра появится другой " + "двигатель, машине будет достаточно того же метода.", + """class Engine: + def __init__(self, power): + self.power = power + +class Car: + def __init__(self, engine): + self.engine = engine + + def describe(self): + return f'Машина с двигателем {self.engine.power} л.с.' + +print(Car(Engine(150)).describe()) +# Вывод: +# Машина с двигателем 150 л.с.""", + "Передавай зависимость аргументом, а не создавай её жёстко внутри.", + ), + _card( + "Типичная ошибка: наследование вместо композиции", + "Иногда `Car` делают наследником `Engine`, потому что хочется взять готовое " + "поле `power`. Тогда машина становится разновидностью двигателя, хотя это не " + "так. Проверить просто: `isinstance(car, Engine)` истинно, а отдельного " + "`car.engine` нет. Если объект «состоит из», выбирай поле, а не родителя.", + """class Engine: + def __init__(self, power): + self.power = power + +class Car(Engine): + def __init__(self, power): + self.power = power + +car = Car(150) +print(isinstance(car, Engine)) +print(hasattr(car, 'engine')) +# Вывод: +# True +# False""", + "Наследование говорит «является», композиция — «содержит».", + ), + ], + "questions": [ + _choice( + "oop-design-composition", + "Что выведет код?\n\n" + "class Engine:\n" + " def __init__(self, power):\n" + " self.power = power\n" + "\n" + "class Car:\n" + " def __init__(self, engine):\n" + " self.engine = engine\n" + "\n" + "print([Car(Engine(p)).engine.power for p in [100, 150]])", + ["[100, 150]", "[150, 100]", "[150, 150]", "[100, 100]"], + "[100, 150]", + "Каждый `Car` получил свой `Engine`, а обход сохранил порядок мощностей.", + ), + _term( + "oop-design-composition", + "Как называется отношение, когда один объект хранит другой объект как свою часть?", + ["композиция", "composition", "композиция объектов", "has-a"], + "Объект-часть хранится в поле, поэтому это композиция, а не наследование.", + ), + _code( + "oop-design-composition", + "Создай `class Engine` с конструктором `__init__(self, power)` и `class Car` " + "с конструктором `__init__(self, engine)`. Метод `describe()` возвращает " + "`'Машина с двигателем 150 л.с.'` для `Car(Engine(150))`.", + """class Engine: + def __init__(self, power): + pass + +class Car: + def __init__(self, engine): + pass + + def describe(self): + pass +""", + [ + { + "kind": "call", + "call": "Car(Engine(150)).describe()", + "expected": "Машина с двигателем 150 л.с.", + }, + { + "kind": "call", + "call": "Car(Engine(0)).describe()", + "expected": "Машина с двигателем 0 л.с.", + }, + ], + """class Engine: + def __init__(self, power): + self.power = power + +class Car: + def __init__(self, engine): + self.engine = engine + + def describe(self): + return f'Машина с двигателем {self.engine.power} л.с.' +""", + "`Car` хранит готовый `Engine` в поле и читает его мощность в `describe`.", + "Сохрани двигатель как `self.engine`, а в строке возьми `self.engine.power`.", + ), + ], + }, + { + "id": "oop-advanced-inheritance", + "theory": [ + _card( + "Что такое наследование", + "Наследование позволяет новому классу взять поведение родителя и добавить своё. " + "`Dog` является `Animal`, поэтому получает его методы. Родительский конструктор " + "вызывают через `super().__init__(...)`, чтобы общее состояние создалось один " + "раз. Дочерний класс дополняет родителя, а не копирует его.", + """class Animal: + def __init__(self, name): + self.name = name + +class Dog(Animal): + def __init__(self, name): + super().__init__(name) + +dog = Dog('Шарик') +print(dog.name) +# Вывод: +# Шарик""", + "Наследование — отношение «является».", + ), + _card( + "Как пользоваться: расширяй, а не переписывай", + "Если метод родителя почти подходит, переопредели только отличие. В примере " + "`Animal.speak` возвращает заглушку, а `Dog.speak` добавляет имя и лай. " + "`super().__init__` сохраняет `name` в том же месте, где это делает родитель. " + "Так изменение родительского конструктора подхватится всеми наследниками.", + """class Animal: + def __init__(self, name): + self.name = name + + def speak(self): + return '...' + +class Dog(Animal): + def __init__(self, name): + super().__init__(name) + + def speak(self): + return f'{self.name}: Гав' + +print(Dog('Шарик').speak()) +# Вывод: +# Шарик: Гав""", + "Сначала вызови `super().__init__`, потом добавляй свои поля.", + ), + _card( + "Типичная ошибка: не вызвать super().__init__", + "Если дочерний конструктор просто содержит `pass`, родительское состояние не " + "создаётся. Объект появляется, но `name` в нём нет, и обращение к нему упадёт " + "позже, в другом методе. Заметь это через `hasattr` сразу после создания. " + "Исправление — вызвать `super().__init__(name)` первой строкой.", + """class Animal: + def __init__(self, name): + self.name = name + +class Dog(Animal): + def __init__(self, name): + pass + +dog = Dog('Шарик') +print(hasattr(dog, 'name')) +# Вывод: +# False""", + "Наследник не получает атрибуты родителя без вызова его конструктора.", + ), + ], + "questions": [ + _choice( + "oop-advanced-inheritance", + "Что выведет код?\n\n" + "class Animal:\n" + " def speak(self):\n" + " return '...'\n" + "\n" + "class Dog(Animal):\n" + " def speak(self):\n" + " return 'Гав'\n" + "\n" + "print(Animal().speak(), Dog().speak())", + ["... Гав", "Гав ...", "... ...", "Гав Гав"], + "... Гав", + "`Animal` использует свой метод, а `Dog` переопределил его и вернул лай.", + ), + _term( + "oop-advanced-inheritance", + "Какая функция вызывает метод родительского класса из дочернего?", + ["super", "super()", "вызов super", "функция super"], + "`super()` даёт доступ к методам родителя, в том числе к его конструктору.", + ), + _code( + "oop-advanced-inheritance", + "Создай `class Animal` с конструктором `__init__(self, name)` и `class Dog`, " + "который наследует `Animal`, вызывает `super().__init__(name)` и возвращает из " + "`speak()` строку `'Шарик: Гав'`.", + """class Animal: + def __init__(self, name): + pass + +class Dog(Animal): + def __init__(self, name): + pass + + def speak(self): + pass +""", + [ + {"kind": "call", "call": "Dog('Шарик').speak()", "expected": "Шарик: Гав"}, + {"kind": "call", "call": "Dog('Шарик').name", "expected": "Шарик"}, + {"kind": "call", "call": "Dog('').speak()", "expected": ": Гав"}, + ], + """class Animal: + def __init__(self, name): + self.name = name + +class Dog(Animal): + def __init__(self, name): + super().__init__(name) + + def speak(self): + return f'{self.name}: Гав' +""", + "`Dog` унаследовал `Animal`, сохранил имя через `super().__init__` и добавил лай.", + "В `Dog.__init__` вызови `super().__init__(name)`, а в `speak` верни " + "f-строку с `self.name`.", + ), + ], + }, + { + "id": "oop-advanced-properties", + "theory": [ + _card( + "Что такое свойство", + "Свойство — это метод, к которому обращаются как к атрибуту. Декоратор " + "`@property` прячет вызов скобок: снаружи `temperature.fahrenheit` выглядит " + "как поле. Внутри может быть формула, проверка или обращение к другому полю. " + "Так удобное имя не раскрывает, как значение получено.", + """class Temperature: + def __init__(self, celsius): + self.celsius = celsius + + @property + def fahrenheit(self): + return self.celsius * 9 / 5 + 32 + +temperature = Temperature(100) +print(temperature.fahrenheit) +# Вывод: +# 212.0""", + "Свойство читают без скобок: `obj.value`, а не `obj.value()`.", + ), + _card( + "Как пользоваться: вычисляемое значение и запись", + "Свойство хорошо подходит для производного значения, которое не нужно хранить " + "дважды. Читая `fahrenheit`, ты получаешь пересчёт из `celsius`. Сеттер через " + "`@fahrenheit.setter` переводит записанное значение обратно в `celsius`. " + "Снаружи работают с привычной шкалой, а внутри хранится одно поле.", + """class Temperature: + def __init__(self, celsius): + self.celsius = celsius + + @property + def fahrenheit(self): + return self.celsius * 9 / 5 + 32 + + @fahrenheit.setter + def fahrenheit(self, value): + self.celsius = (value - 32) * 5 / 9 + +temperature = Temperature(0) +temperature.fahrenheit = 212 +print(temperature.celsius) +# Вывод: +# 100.0""", + "Храни одно исходное значение, остальное вычисляй.", + ), + _card( + "Типичная ошибка: забыть @property", + "Без декоратора метод остаётся методом: `temperature.fahrenheit` возвращает " + "функцию, а не число. `print` покажет её, вычисления сломаются, и ошибка " + "проявится далеко от места определения. Заметь это через `callable`: у " + "настоящего свойства результат — число. Исправление — поставить `@property` " + "над методом.", + """class Temperature: + def __init__(self, celsius): + self.celsius = celsius + + def fahrenheit(self): + return self.celsius * 9 / 5 + 32 + +temperature = Temperature(100) +print(callable(temperature.fahrenheit)) +print(temperature.fahrenheit()) +# Вывод: +# True +# 212.0""", + "Если для получения значения нужны скобки, свойство не сработало.", + ), + ], + "questions": [ + _choice( + "oop-advanced-properties", + "Что выведет код?\n\n" + "class Temperature:\n" + " def __init__(self, celsius):\n" + " self.celsius = celsius\n" + "\n" + " @property\n" + " def fahrenheit(self):\n" + " return self.celsius * 9 / 5 + 32\n" + "\n" + "temperature = Temperature(0)\n" + "print(temperature.fahrenheit)", + ["32.0", "0.00", "100.0", "212.0"], + "32.0", + "0 градусов по Цельсию переводится в 32 градуса по Фаренгейту, и деление даёт float.", + ), + _term( + "oop-advanced-properties", + "Каким декоратором помечают метод, чтобы обращаться к нему как к атрибуту?", + ["@property", "property", "декоратор property", "@property()"], + "`@property` превращает метод в вычисляемый атрибут без скобок при чтении.", + ), + _code( + "oop-advanced-properties", + "Создай `class Temperature` с конструктором `__init__(self, celsius)` и " + "свойством `fahrenheit`, которое переводит температуру по формуле " + "`celsius * 9 / 5 + 32`.", + """class Temperature: + def __init__(self, celsius): + pass + + def fahrenheit(self): + pass +""", + [ + {"kind": "call", "call": "Temperature(100).fahrenheit", "expected": 212.0}, + {"kind": "call", "call": "Temperature(0).fahrenheit", "expected": 32.0}, + {"kind": "call", "call": "Temperature(-40).fahrenheit", "expected": -40.0}, + ], + """class Temperature: + def __init__(self, celsius): + self.celsius = celsius + + @property + def fahrenheit(self): + return self.celsius * 9 / 5 + 32 +""", + "`@property` позволил читать `fahrenheit` без скобок, а формула пересчитала шкалу.", + "Поставь `@property` над `fahrenheit` и верни `self.celsius * 9 / 5 + 32`.", + ), + ], + }, + { + "id": "oop-advanced-protocols", + "theory": [ + _card( + "Что такое протокол и утиная типизация", + "Протокол — это набор методов, которых достаточно для работы с объектом. " + "Python часто не спрашивает класс, а просто вызывает нужный метод: если у " + "объекта есть `area()`, его можно считать фигурой. Это называют утиной " + "типизацией. Разные классы подходят под один код без общего родителя.", + """class Square: + def __init__(self, side): + self.side = side + + def area(self): + return self.side ** 2 + +class Rect: + def __init__(self, w, h): + self.w = w + self.h = h + + def area(self): + return self.w * self.h + +for shape in [Square(2), Rect(2, 3)]: + print(shape.area()) +# Вывод: +# 4 +# 6""", + "Смотри на методы, а не на имя класса.", + ), + _card( + "Как пользоваться: общий код для разных классов", + "Функция `total_area` перебирает фигуры и складывает `shape.area()`. Ей не " + "важно, квадрат это или прямоугольник: важен только метод. Поэтому новый " + "класс с `area()` сразу работает со старой функцией. Держи протокол " + "маленьким: один-два метода, которые действительно нужны.", + """class Square: + def __init__(self, side): + self.side = side + + def area(self): + return self.side ** 2 + +class Rect: + def __init__(self, w, h): + self.w = w + self.h = h + + def area(self): + return self.w * self.h + +def total_area(shapes): + return sum(shape.area() for shape in shapes) + +print(total_area([Square(2), Rect(2, 3)])) +# Вывод: +# 10""", + "Проверяй, что функция вызывает только методы протокола.", + ), + _card( + "Типичная ошибка: проверять класс через isinstance", + "Если функция принимает только `Square`, прямоугольник молча выпадет из " + "суммы. Исключения не будет: результат просто станет меньше. Заметь это по " + "количеству обработанных фигур или по сумме, которая не сходится. Убирай " + "проверку класса, если все объекты умеют нужный метод.", + """class Square: + def __init__(self, side): + self.side = side + + def area(self): + return self.side ** 2 + +class Rect: + def __init__(self, w, h): + self.w = w + self.h = h + + def area(self): + return self.w * self.h + +def total_area(shapes): + total = 0 + for shape in shapes: + if isinstance(shape, Square): + total += shape.area() + return total + +print(total_area([Square(2), Rect(2, 3)])) +# Вывод: +# 4""", + "Сравни количество фигур на входе и слагаемых в сумме.", + ), + ], + "questions": [ + _choice( + "oop-advanced-protocols", + "Что выведет код?\n\n" + "class Square:\n" + " def __init__(self, side):\n" + " self.side = side\n" + "\n" + " def area(self):\n" + " return self.side ** 2\n" + "\n" + "class Rect:\n" + " def __init__(self, w, h):\n" + " self.w = w\n" + " self.h = h\n" + "\n" + " def area(self):\n" + " return self.w * self.h\n" + "\n" + "def total_area(shapes):\n" + " return sum(shape.area() for shape in shapes)\n" + "\n" + "print(total_area([Square(3), Rect(2, 4)]))", + ["17", "14", "20", "24"], + "17", + "Квадрат дал 9, прямоугольник — 8, а `sum` сложил оба значения.", + ), + _term( + "oop-advanced-protocols", + "Как называется подход, когда код опирается на наличие метода, а не на класс?", + [ + "утиная типизация", + "duck typing", + "duck-typing", + "утиная типизация (duck typing)", + ], + "Утиная типизация проверяет поведение: есть метод — объект подходит.", + ), + _code( + "oop-advanced-protocols", + "Создай `class Square` с `area()`, `class Rect` с `area()` и функцию " + "`total_area(shapes)`, которая складывает площади всех фигур через их общий " + "метод, без проверки класса.", + """class Square: + def __init__(self, side): + pass + + def area(self): + pass + +class Rect: + def __init__(self, w, h): + pass + + def area(self): + pass + +def total_area(shapes): + pass +""", + [ + {"kind": "call", "call": "total_area([Square(2), Rect(2, 3)])", "expected": 10}, + {"kind": "call", "call": "total_area([])", "expected": 0}, + {"kind": "call", "call": "total_area([Square(0)])", "expected": 0}, + ], + """class Square: + def __init__(self, side): + self.side = side + + def area(self): + return self.side ** 2 + +class Rect: + def __init__(self, w, h): + self.w = w + self.h = h + + def area(self): + return self.w * self.h + +def total_area(shapes): + return sum(shape.area() for shape in shapes) +""", + "`total_area` не проверяет классы: она просто вызывает `area()` у каждой фигуры.", + "Сложи `shape.area()` для всех элементов через `sum`, не используя `isinstance`.", + ), + ], + }, + { + "id": "oop-advanced-special-methods", + "theory": [ + _card( + "Что такое специальные методы", + "Специальные методы с двойными подчёркиваниями подключают объект к синтаксису " + "Python. `__add__` вызывается при `+`, `__eq__` — при `==`, `__repr__` — при " + "печати в отладке. Ты не вызываешь их напрямую: Python делает это сам. Так " + "твой класс начинает вести себя как встроенное значение.", + """class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __add__(self, other): + return Vector(self.x + other.x, self.y + other.y) + + def __repr__(self): + return f'Vector({self.x}, {self.y})' + +print(Vector(1, 2) + Vector(3, 4)) +# Вывод: +# Vector(4, 6)""", + "Имя метода — это операция, которую Python выполняет за тебя.", + ), + _card( + "Как пользоваться: сложение возвращает новый объект", + "`__add__` получает второй объект и должен вернуть новый результат, а не менять " + "исходные. Сложи поля по отдельности и собери новый `Vector`. `__eq__` " + "сравнивает состояние и возвращает `True` или `False`. Вместе они позволяют " + "писать `(a + b) == c`.", + """class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __eq__(self, other): + return self.x == other.x and self.y == other.y + +print(Vector(1, 2) == Vector(1, 2)) +print(Vector(1, 2) == Vector(2, 1)) +# Вывод: +# True +# False""", + "Не меняй `self` в `__add__`: создавай новый экземпляр.", + ), + _card( + "Типичная ошибка: сравнить только часть полей", + "Если `__eq__` смотрит только на `x`, векторы с разными `y` окажутся равными. " + "Код не падает, но условие `==` начинает врать. Заметь это на паре объектов, " + "которые отличаются одним полем. Сравнивай все поля, которые задают состояние.", + """class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __eq__(self, other): + return self.x == other.x + +print(Vector(1, 2) == Vector(1, 9)) +# Вывод: +# True""", + "Проверь `==` на объектах, у которых совпадает только одно поле.", + ), + ], + "questions": [ + _choice( + "oop-advanced-special-methods", + "Что выведет код?\n\n" + "class Vector:\n" + " def __init__(self, x, y):\n" + " self.x = x\n" + " self.y = y\n" + "\n" + " def __add__(self, other):\n" + " return Vector(self.x + other.x, self.y + other.y)\n" + "\n" + " def __repr__(self):\n" + " return f'Vector({self.x}, {self.y})'\n" + "\n" + "print(Vector(2, 3) + Vector(4, 5))", + ["Vector(6, 8)", "Vector(8, 6)", "Vector(6, 9)", "Vector(4, 5)"], + "Vector(6, 8)", + "`__add__` сложил координаты по отдельности: 2 + 4 и 3 + 5.", + ), + _term( + "oop-advanced-special-methods", + "Какой специальный метод вызывается при сложении объектов через `+`?", + ["__add__", "add", "__add__()", "метод __add__"], + "Оператор `+` вызывает `__add__` у левого объекта.", + ), + _code( + "oop-advanced-special-methods", + "Создай `class Vector` с конструктором `__init__(self, x, y)`, методом " + "`__add__` для сложения двух векторов и методом `__eq__` для сравнения координат.", + """class Vector: + def __init__(self, x, y): + pass + + def __add__(self, other): + pass + + def __eq__(self, other): + pass +""", + [ + { + "kind": "call", + "call": "(Vector(1, 2) + Vector(3, 4)) == Vector(4, 6)", + "expected": True, + }, + { + "kind": "call", + "call": "(Vector(0, 0) == Vector(0, 1)) is False", + "expected": True, + }, + {"kind": "call", "call": "Vector(0, 0) == Vector(0, 0)", "expected": True}, + ], + """class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __add__(self, other): + return Vector(self.x + other.x, self.y + other.y) + + def __eq__(self, other): + return self.x == other.x and self.y == other.y +""", + "`__add__` вернул новый вектор, а `__eq__` сравнил обе координаты.", + "В `__add__` создай `Vector(self.x + other.x, self.y + other.y)`, а в " + "`__eq__` сравни `x` и `y` через `and`.", + ), + ], + }, +] diff --git a/tests/test_handwritten_oop.py b/tests/test_handwritten_oop.py new file mode 100644 index 0000000..334d500 --- /dev/null +++ b/tests/test_handwritten_oop.py @@ -0,0 +1,552 @@ +import ast +import importlib +from pathlib import Path +from typing import Any + +import pytest + +from app.evaluator import evaluate, run_code + +APP_DIR = Path(__file__).resolve().parents[1] / "app" +MODULE_NAME = "app.handwritten.lessons_oop" +MODULE_PATH = APP_DIR / "handwritten" / "lessons_oop.py" +LESSON_SPECS = { + "oop-design-init": (65, "Конструктор"), + "oop-design-repr-str": (66, "__repr__ и __str__"), + "oop-design-class-instance": (67, "Атрибуты класса и экземпляра"), + "oop-design-composition": (68, "Композиция"), + "oop-advanced-inheritance": (69, "Наследование"), + "oop-advanced-properties": (70, "Свойства"), + "oop-advanced-protocols": (71, "Абстракции и протоколы"), + "oop-advanced-special-methods": (72, "Специальные методы"), +} +LESSON_IDS = list(LESSON_SPECS) +FORBIDDEN_PHRASES = ( + "Когда это применять", + "Сформулируй правило своими словами", + "Ориентир:", + "относится к теме", +) +OUTPUT_MARKER = "# Вывод:" +PREDICT_WORDS = ("выведет", "напечатает") +UNDERSTANDING_WORDS = ( + "вывод", + "выведет", + "напечатает", + "верн", + "будет", + "ошиб", + "выбери", + "найди", + "какой код", +) +NAME_RECALL_WORDS = ("название", "называ") +FORM_FEATURES = { + "обратные кавычки": lambda text: "`" in text, + "точка в конце": lambda text: text.rstrip().endswith("."), + "заглавная первая буква": lambda text: text[:1].isupper(), + "скобки": lambda text: any(char in text for char in "()[]{}"), +} + + +def _registry() -> dict[str, dict[str, Any]]: + try: + return importlib.import_module("app.handwritten").HANDWRITTEN + except Exception as error: + raise AssertionError(f"app.handwritten не собирает реестр: {error!r}") from error + + +def _course() -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + try: + from app.content import LESSON_BY_ID, LESSONS + except Exception as error: + raise AssertionError(f"app.content не собирает курс: {error!r}") from error + return LESSONS, LESSON_BY_ID + + +def _declared_lessons() -> list[dict[str, Any]]: + assert MODULE_PATH.is_file(), f"нет файла {MODULE_PATH.name} в app/handwritten" + try: + lessons = importlib.import_module(MODULE_NAME).LESSONS + except Exception as error: + raise AssertionError(f"{MODULE_NAME} не импортируется: {error!r}") from error + assert isinstance(lessons, list), "LESSONS должен быть списком уроков" + assert all(isinstance(lesson, dict) for lesson in lessons), "каждый урок должен быть словарём" + return lessons + + +def _lesson(lesson_id: str) -> dict[str, Any]: + registry = _registry() + assert lesson_id in registry, f"нет рукописного урока {lesson_id}" + return registry[lesson_id] + + +def _cards(lesson_id: str) -> list[dict[str, Any]]: + theory = _lesson(lesson_id).get("theory", []) + assert len(theory) == 3, (lesson_id, "нужно ровно 3 карточки") + assert all(isinstance(card, dict) for card in theory), lesson_id + return theory + + +def _questions(lesson_id: str) -> list[dict[str, Any]]: + questions = _lesson(lesson_id).get("questions", []) + assert len(questions) == 3, (lesson_id, "нужно ровно 3 задания") + assert all(isinstance(question, dict) for question in questions), lesson_id + return questions + + +def _code_question(lesson_id: str) -> dict[str, Any]: + code = _questions(lesson_id)[2] + assert code.get("kind") == "code", lesson_id + assert isinstance(code.get("reference"), str), lesson_id + return code + + +def _call(call: str, expected: Any) -> dict[str, Any]: + return {"kind": "call", "call": call, "expected": expected} + + +def _strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [text for item in value.values() for text in _strings(item)] + if isinstance(value, list | tuple): + return [text for item in value for text in _strings(item)] + return [] + + +def _declared_output(example: str) -> list[str]: + """Строки вывода из комментария «# Вывод:» — то, что пример должен напечатать.""" + lines = example.splitlines() + marks = [index for index, line in enumerate(lines) if line.strip().startswith(OUTPUT_MARKER)] + if not marks: + return [] + start = marks[0] + declared = [] + head = lines[start].strip()[len(OUTPUT_MARKER) :].strip() + if head: + declared.append(head) + for line in lines[start + 1 :]: + stripped = line.strip() + if not stripped.startswith("#"): + break + text = stripped[1:].strip() + if text: + declared.append(text) + return declared + + +def _parses(source: str) -> bool: + try: + ast.parse(source) + except SyntaxError: + return False + return True + + +def _assert_calls(source: str, calls: list[dict[str, Any]]) -> None: + result = run_code(source, calls) + assert result["error"] is None, result["error"] + assert result["correct"] is True, result["checks"] + + +def _assert_starter_stub(code: dict[str, Any], names: set[str]) -> None: + starter = ast.parse(code.get("starter", "")) + defined = { + node.name for node in ast.walk(starter) if isinstance(node, ast.ClassDef | ast.FunctionDef) + } + assert names <= defined, (names, defined) + assert any(isinstance(node, ast.Pass) for node in ast.walk(starter)), "starter без pass" + + +def _class_def(tree: ast.Module, name: str) -> ast.ClassDef: + found = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == name] + assert found, f"нет class {name}" + return found[0] + + +def _def(scope: ast.ClassDef | ast.Module, name: str) -> ast.FunctionDef: + found = [node for node in scope.body if isinstance(node, ast.FunctionDef) and node.name == name] + assert found, f"нет def {name}" + return found[0] + + +def _params(function: ast.FunctionDef) -> list[str]: + return [arg.arg for arg in function.args.args] + + +def _self_attributes(function: ast.FunctionDef) -> set[str]: + return { + target.attr + for node in ast.walk(function) + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + } + + +def _class_attributes(cls: ast.ClassDef) -> dict[str, ast.expr]: + attributes: dict[str, ast.expr] = {} + for node in cls.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + attributes[target.id] = node.value + return attributes + + +def _is_super_init_call(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "__init__" + and isinstance(node.func.value, ast.Call) + and isinstance(node.func.value.func, ast.Name) + and node.func.value.func.id == "super" + ) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c1_lessons_module_declares_its_lesson(lesson_id: str) -> None: + lessons = _declared_lessons() + assert lesson_id in [lesson.get("id") for lesson in lessons], lesson_id + + +def test_c1_lessons_module_declares_exactly_eight_lessons() -> None: + lessons = _declared_lessons() + assert sorted(str(lesson.get("id")) for lesson in lessons) == sorted(LESSON_IDS) + + +def test_c1_registry_exposes_all_eight_ids() -> None: + registry = _registry() + missing = sorted(set(LESSON_IDS) - set(registry)) + assert not missing, f"нет в app.handwritten.HANDWRITTEN: {missing}" + for lesson_id in LESSON_IDS: + assert registry[lesson_id].get("id") == lesson_id, lesson_id + + +def test_c1_course_keeps_120_lessons_with_oop_orders_and_titles() -> None: + lessons, lesson_by_id = _course() + assert len(lessons) == 120 + assert [lesson["order"] for lesson in lessons] == list(range(1, 121)) + for lesson_id, (order, title) in LESSON_SPECS.items(): + assert lesson_id in lesson_by_id, lesson_id + assert lesson_by_id[lesson_id]["order"] == order, lesson_id + assert lesson_by_id[lesson_id]["title"] == title, lesson_id + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_lesson_has_three_cards_with_text_and_example(lesson_id: str) -> None: + lesson = _lesson(lesson_id) + assert lesson["id"] == lesson_id + for card in _cards(lesson_id): + for key in ("title", "text", "example"): + assert isinstance(card.get(key), str) and card[key].strip(), (lesson_id, key) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_runnable_cards_execute_in_runner(lesson_id: str) -> None: + for card in _cards(lesson_id): + if card.get("run") is False: + continue + result = run_code(card["example"], []) + assert result["error"] is None, (lesson_id, card["title"], result["error"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_questions_have_choice_input_code_ids(lesson_id: str) -> None: + questions = _questions(lesson_id) + assert [question.get("id") for question in questions] == [ + f"{lesson_id}-choice", + f"{lesson_id}-term", + f"{lesson_id}-code", + ] + assert [question.get("kind") for question in questions] == ["choice", "input", "code"] + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_choice_has_unique_options_and_answer_among_them(lesson_id: str) -> None: + choice = _questions(lesson_id)[0] + options = choice["options"] + assert len(options) >= 3, lesson_id + assert len(set(options)) == len(options), (lesson_id, "варианты повторяются") + assert choice["answer"] in options, lesson_id + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_input_has_short_nonempty_answers(lesson_id: str) -> None: + term = _questions(lesson_id)[1] + answers = term["answers"] + assert isinstance(answers, list) and answers, lesson_id + for answer in answers: + assert isinstance(answer, str) and answer.strip(), lesson_id + assert len(answer) <= 40, (lesson_id, answer) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_every_question_has_explanation_and_code_has_hint(lesson_id: str) -> None: + choice, term, code = _questions(lesson_id) + for question in (choice, term, code): + assert isinstance(question.get("explanation"), str), (lesson_id, question.get("id")) + assert question["explanation"].strip(), (lesson_id, question.get("id")) + assert isinstance(code.get("hint"), str) and code["hint"].strip(), lesson_id + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_code_reference_passes_and_starter_fails(lesson_id: str) -> None: + code = _code_question(lesson_id) + assert isinstance(code.get("tests"), list) and len(code["tests"]) >= 2, lesson_id + assert isinstance(code.get("starter"), str), lesson_id + assert evaluate(code, code["reference"])["correct"] is True, lesson_id + assert evaluate(code, code["starter"])["correct"] is False, lesson_id + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_lesson_has_no_generator_template_phrases(lesson_id: str) -> None: + for text in _strings(_lesson(lesson_id)): + for phrase in FORBIDDEN_PHRASES: + assert phrase not in text, (lesson_id, phrase) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_third_card_is_typical_mistake(lesson_id: str) -> None: + theory = _cards(lesson_id) + assert "ошиб" in theory[2]["title"].casefold(), (lesson_id, theory[2]["title"]) + assert "ошиб" not in theory[0]["title"].casefold(), (lesson_id, theory[0]["title"]) + assert "ошиб" not in theory[1]["title"].casefold(), (lesson_id, theory[1]["title"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_every_example_declares_output(lesson_id: str) -> None: + for card in _cards(lesson_id): + assert OUTPUT_MARKER in card["example"], (lesson_id, card["title"]) + assert _declared_output(card["example"]), (lesson_id, card["title"], "вывод пустой") + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_declared_output_matches_real_stdout(lesson_id: str) -> None: + for card in _cards(lesson_id): + if card.get("run") is False: + continue + result = run_code(card["example"], []) + assert result["error"] is None, (lesson_id, card["title"], result["error"]) + actual = [line.strip() for line in result["stdout"].splitlines() if line.strip()] + assert _declared_output(card["example"]) == actual, (lesson_id, card["title"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_choice_asks_to_predict_or_find_not_recall_name(lesson_id: str) -> None: + prompt = _questions(lesson_id)[0]["prompt"].casefold() + assert any(word in prompt for word in UNDERSTANDING_WORDS), (lesson_id, prompt) + assert not any(word in prompt for word in NAME_RECALL_WORDS), (lesson_id, prompt) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_predicted_output_choice_matches_real_run(lesson_id: str) -> None: + choice = _questions(lesson_id)[0] + head, _, body = choice["prompt"].partition("\n\n") + if not any(word in head.casefold() for word in PREDICT_WORDS): + return + assert body.strip(), (lesson_id, "предсказание вывода без кода") + result = run_code(body, []) + assert result["error"] is None, (lesson_id, result["error"]) + assert result["stdout"].strip() == choice["answer"].strip(), ( + lesson_id, + result["stdout"], + choice["answer"], + ) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_choice_answer_does_not_stand_out_by_form_or_length(lesson_id: str) -> None: + choice = _questions(lesson_id)[0] + options = choice["options"] + answer = choice["answer"] + assert 3 <= len(options) <= 4, (lesson_id, options) + + for name, feature in FORM_FEATURES.items(): + values = [feature(option) for option in options] + if len(set(values)) > 1: + assert values.count(feature(answer)) > 1, (lesson_id, name, answer) + + lengths = [len(option) for option in options] + assert max(lengths) <= 1.6 * min(lengths), (lesson_id, lengths) + others = [len(option) for option in options if option != answer] + assert min(others) <= len(answer) <= max(others), (lesson_id, answer) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c4_run_false_only_where_runner_rejects_example(lesson_id: str) -> None: + for card in _cards(lesson_id): + flag = card.get("run", True) + assert flag in (True, False), (lesson_id, card["title"], flag) + result = run_code(card["example"], []) + if flag is False: + assert result["error"] is not None, (lesson_id, card["title"], "пример исполняется") + assert result["error"].startswith("ValueError"), (lesson_id, result["error"]) + else: + assert result["error"] is None, (lesson_id, card["title"], result["error"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c4_every_example_is_real_python_even_without_run(lesson_id: str) -> None: + for card in _cards(lesson_id): + assert _parses(card["example"]), (lesson_id, card["title"]) + + +def test_k65_point_distance_to_origin_and_x() -> None: + code = _code_question("oop-design-init") + tree = ast.parse(code["reference"]) + point = _class_def(tree, "Point") + assert _params(_def(point, "__init__")) == ["self", "x", "y"] + assert _params(_def(point, "distance_to_origin")) == ["self"] + _assert_starter_stub(code, {"Point"}) + _assert_calls( + code["reference"], + [ + _call("Point(3, 4).distance_to_origin()", 5.0), + _call("Point(0, 0).x", 0), + ], + ) + + +def test_k66_money_str_and_repr() -> None: + code = _code_question("oop-design-repr-str") + tree = ast.parse(code["reference"]) + money = _class_def(tree, "Money") + assert _params(_def(money, "__init__")) == ["self", "amount", "currency"] + _def(money, "__str__") + _def(money, "__repr__") + _assert_starter_stub(code, {"Money"}) + _assert_calls( + code["reference"], + [ + _call("str(Money(100, 'RUB'))", "100 RUB"), + _call("repr(Money(5, 'USD'))", "Money(5, 'USD')"), + ], + ) + + +def test_k67_cat_species_is_class_attribute_and_name_is_instance_attribute() -> None: + code = _code_question("oop-design-class-instance") + tree = ast.parse(code["reference"]) + cat = _class_def(tree, "Cat") + species = _class_attributes(cat).get("species") + assert isinstance(species, ast.Constant) and species.value == "кошка", ( + "species не атрибут класса" + ) + init = _def(cat, "__init__") + assert _params(init) == ["self", "name"] + assert "name" in _self_attributes(init), "name должен быть атрибутом экземпляра" + _assert_starter_stub(code, {"Cat"}) + _assert_calls( + code["reference"], + [ + _call("Cat('Мурка').species", "кошка"), + _call("Cat('Барсик').name", "Барсик"), + ], + ) + + +def test_k68_car_composes_engine_without_inheritance() -> None: + code = _code_question("oop-design-composition") + tree = ast.parse(code["reference"]) + engine = _class_def(tree, "Engine") + assert _params(_def(engine, "__init__")) == ["self", "power"] + car = _class_def(tree, "Car") + assert _params(_def(car, "__init__")) == ["self", "engine"] + assert "engine" in _self_attributes(_def(car, "__init__")), "Car должен хранить engine" + assert not car.bases, "Car не должен наследовать Engine: здесь композиция" + _def(car, "describe") + _assert_starter_stub(code, {"Engine", "Car"}) + _assert_calls( + code["reference"], + [_call("Car(Engine(150)).describe()", "Машина с двигателем 150 л.с.")], + ) + + +def test_k69_dog_inherits_animal_and_calls_super_init() -> None: + code = _code_question("oop-advanced-inheritance") + tree = ast.parse(code["reference"]) + animal = _class_def(tree, "Animal") + assert _params(_def(animal, "__init__")) == ["self", "name"] + dog = _class_def(tree, "Dog") + assert len(dog.bases) == 1 and isinstance(dog.bases[0], ast.Name), "Dog должен наследовать" + assert dog.bases[0].id == "Animal", "Dog должен наследовать Animal" + assert any(_is_super_init_call(node) for node in ast.walk(dog)), "нужен super().__init__()" + _def(dog, "speak") + _assert_starter_stub(code, {"Animal", "Dog"}) + _assert_calls( + code["reference"], + [ + _call("Dog('Шарик').speak()", "Шарик: Гав"), + _call("Dog('Шарик').name", "Шарик"), + ], + ) + + +def test_k70_temperature_fahrenheit_is_property() -> None: + code = _code_question("oop-advanced-properties") + tree = ast.parse(code["reference"]) + temperature = _class_def(tree, "Temperature") + assert _params(_def(temperature, "__init__")) == ["self", "celsius"] + fahrenheit = _def(temperature, "fahrenheit") + assert any( + isinstance(decorator, ast.Name) and decorator.id == "property" + for decorator in fahrenheit.decorator_list + ), "fahrenheit должен быть @property" + _assert_starter_stub(code, {"Temperature"}) + _assert_calls( + code["reference"], + [ + _call("Temperature(100).fahrenheit", 212.0), + _call("Temperature(0).fahrenheit", 32.0), + ], + ) + + +def test_k71_total_area_works_by_duck_typing() -> None: + code = _code_question("oop-advanced-protocols") + tree = ast.parse(code["reference"]) + square = _class_def(tree, "Square") + assert _params(_def(square, "__init__")) == ["self", "side"] + _def(square, "area") + rect = _class_def(tree, "Rect") + assert _params(_def(rect, "__init__")) == ["self", "w", "h"] + _def(rect, "area") + total_area = _def(tree, "total_area") + assert _params(total_area) == ["shapes"] + assert not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "isinstance" + for node in ast.walk(total_area) + ), "total_area не должен проверять класс через isinstance" + _assert_starter_stub(code, {"Square", "Rect", "total_area"}) + _assert_calls( + code["reference"], + [ + _call("total_area([Square(2), Rect(2, 3)])", 10), + _call("total_area([])", 0), + ], + ) + + +def test_k72_vector_add_and_eq() -> None: + code = _code_question("oop-advanced-special-methods") + tree = ast.parse(code["reference"]) + vector = _class_def(tree, "Vector") + assert _params(_def(vector, "__init__")) == ["self", "x", "y"] + _def(vector, "__add__") + _def(vector, "__eq__") + _assert_starter_stub(code, {"Vector"}) + _assert_calls( + code["reference"], + [ + _call("(Vector(1, 2) + Vector(3, 4)) == Vector(4, 6)", True), + _call("(Vector(0, 0) == Vector(0, 1)) is False", True), + ], + )