diff --git a/app/content.py b/app/content.py index 256338a..3759def 100644 --- a/app/content.py +++ b/app/content.py @@ -3,6 +3,7 @@ from __future__ import annotations from app.extended_curriculum import EXTRA_EXAMS, EXTRA_LESSONS, EXTRA_MODULES +from app.lessons_13_25 import LESSONS_13_25 def theory(title: str, text: str, example: str, tip: str = "") -> dict: @@ -728,6 +729,7 @@ def lesson( MODULES.extend(EXTRA_MODULES) +LESSONS.extend(LESSONS_13_25) LESSONS.extend(EXTRA_LESSONS) @@ -767,7 +769,9 @@ def lesson( def public_question(question: dict) -> dict: return { - key: value for key, value in question.items() if key not in {"answer", "answers", "tests"} + key: value + for key, value in question.items() + if key not in {"answer", "answers", "tests", "reference"} } diff --git a/app/evaluator.py b/app/evaluator.py index ca71c18..9541c0d 100644 --- a/app/evaluator.py +++ b/app/evaluator.py @@ -84,6 +84,57 @@ def normalize(value: Any) -> str: return str(value or "").strip().casefold() +def _check_source_requirements(tree: ast.AST, tests: list[dict]) -> str | None: + for test in tests: + if test.get("kind") != "source": + continue + if test.get("requires") == "unpacking": + name = test.get("name") + function = test.get("function") + scope: ast.AST = tree + if function: + target_function = next( + ( + node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == function + ), + None, + ) + scope = ast.Module( + body=target_function.body if target_function else [], type_ignores=[] + ) + has_unpacking = any( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Name) + and node.value.id == name + and any(isinstance(target, (ast.Tuple, ast.List)) for target in node.targets) + for node in ast.walk(scope) + ) + uses_index = any(isinstance(node, ast.Subscript) for node in ast.walk(scope)) + if function: + unpacking_values = { + id(node.value) + for node in ast.walk(scope) + if isinstance(node, ast.Assign) + and isinstance(node.value, ast.Name) + and node.value.id == name + and any(isinstance(target, (ast.Tuple, ast.List)) for target in node.targets) + } + uses_parameter_elsewhere = any( + isinstance(node, ast.Name) + and node.id == name + and id(node) not in unpacking_values + for node in ast.walk(scope) + ) + else: + uses_parameter_elsewhere = False + if not has_unpacking or uses_index or uses_parameter_elsewhere: + return "В этом задании нужна распаковка последовательности без индексов." + return None + + def run_code(source: str, tests: list[dict]) -> dict: """Выполняет небольшой фрагмент в отдельном Python-процессе с лимитом времени.""" if len(source) > 5_000: @@ -96,7 +147,8 @@ def run_code(source: str, tests: list[dict]) -> dict: "timed_out": False, } try: - SafetyVisitor().visit(ast.parse(source)) + tree = ast.parse(source) + SafetyVisitor().visit(tree) except (SyntaxError, ValueError) as error: return { "correct": False, @@ -107,10 +159,23 @@ def run_code(source: str, tests: list[dict]) -> dict: "timed_out": False, } + requirement_error = _check_source_requirements(tree, tests) + if requirement_error: + return { + "correct": False, + "message": requirement_error, + "checks": [], + "stdout": "", + "stderr": "", + "error": requirement_error, + "timed_out": False, + } + + runtime_tests = [test for test in tests if test.get("kind") != "source"] encoded_code = base64.b64encode(source.encode("utf-8")).decode("ascii") - encoded_tests = base64.b64encode(json.dumps(tests, ensure_ascii=False).encode("utf-8")).decode( - "ascii" - ) + encoded_tests = base64.b64encode( + json.dumps(runtime_tests, ensure_ascii=False).encode("utf-8") + ).decode("ascii") program = RUNNER.replace("CODE", repr(encoded_code)).replace("TESTS", repr(encoded_tests)) try: result = subprocess.run( diff --git a/app/extended_curriculum.py b/app/extended_curriculum.py index 6ecaa1e..c66775d 100644 --- a/app/extended_curriculum.py +++ b/app/extended_curriculum.py @@ -1,4 +1,4 @@ -"""Расширенная часть курса: 27 модулей по 4 микроурока = 108 новых уроков. +"""Каталог расширенного курса и генератор уроков 26–120. Материал написан для приложения, а не скопирован из внешних источников. Его порядок следует естественной траектории официального Python Tutorial: синтаксис → данные → @@ -1433,34 +1433,35 @@ def build_extended_course() -> tuple[ for lesson_index, spec in enumerate(unit["lessons"]): slug, title, subtitle, keyword, example, concept, advice = spec lesson_id = f"{module_id}-{slug}" - questions = _make_questions(lesson_id, spec, TASK_CYCLES[unit_index][lesson_index]) - lessons.append( - { - "id": lesson_id, - "module_id": module_id, - "order": order, - "title": title, - "subtitle": subtitle, - "duration": 11 + lesson_index, - "xp": 35 + (unit_index // 4) * 5, - "theory": [ - _theory(title, concept, example, f"Ориентир: {keyword}."), - _theory( - "Когда это применять", - f"{subtitle}. Это маленький инструмент, который становится полезным в большом проекте.", - example, - advice, - ), - _theory( - "Проверка понимания", - "Сформулируй правило своими словами и измени пример так, чтобы увидеть другой результат.", - f"# Тема: {title}\n# Ключевой термин: {keyword}", - ), - ], - "questions": questions, - } - ) - question_ids.append(questions[0]["id"]) + question_ids.append(f"{lesson_id}-choice") + if order >= 26: + questions = _make_questions(lesson_id, spec, TASK_CYCLES[unit_index][lesson_index]) + lessons.append( + { + "id": lesson_id, + "module_id": module_id, + "order": order, + "title": title, + "subtitle": subtitle, + "duration": 11 + lesson_index, + "xp": 35 + (unit_index // 4) * 5, + "theory": [ + _theory(title, concept, example, f"Ориентир: {keyword}."), + _theory( + "Когда это применять", + f"{subtitle}. Это маленький инструмент, который становится полезным в большом проекте.", + example, + advice, + ), + _theory( + "Проверка понимания", + "Сформулируй правило своими словами и измени пример так, чтобы увидеть другой результат.", + f"# Тема: {title}\n# Ключевой термин: {keyword}", + ), + ], + "questions": questions, + } + ) order += 1 exams[module_id] = { "title": f"Контрольная точка: {unit['title']}", diff --git a/app/lessons_13_25.py b/app/lessons_13_25.py new file mode 100644 index 0000000..25405a3 --- /dev/null +++ b/app/lessons_13_25.py @@ -0,0 +1,907 @@ +"""Рукописные уроки 13–25 для перехода от основ к коллекциям.""" + +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, + } + + +def _lesson( + lesson_id: str, + module_id: str, + order: int, + title: str, + subtitle: str, + duration: int, + theory: list[dict[str, str]], + questions: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "id": lesson_id, + "module_id": module_id, + "order": order, + "title": title, + "subtitle": subtitle, + "duration": duration, + "xp": 35, + "theory": theory, + "questions": questions, + } + + +LESSONS_13_25 = [ + _lesson( + "operators-arithmetic", + "operators", + 13, + "Арифметика", + "Собираем вычисление из нескольких действий", + 11, + [ + _card( + "Оператор задаёт действие", + "Арифметический оператор — знак действия над числами. `+` складывает, `-` вычитает, `*` умножает, а `/` делит и возвращает дробное число. Несколько значений и операторов образуют выражение: Python вычисляет его и получает одно новое значение. Результат можно сразу вывести или сохранить в переменной.", + "price = 120\ncount = 3\nprint(price * count)\n# Вывод:\n# 360", + ), + _card( + "Скобки меняют порядок", + "Python сначала выполняет действия в скобках, затем умножение и деление, а после них сложение и вычитание. Это называется порядком операций — правилом, какое действие выполнить раньше. Скобки делают намерение заметным даже там, где Python и без них посчитал бы верно. Так длинное выражение легче проверить по частям.", + "price = 100\ncount = 2\ndelivery = 50\nprint(price * count + delivery)\n# Вывод:\n# 250", + ), + _card( + "Типичная ошибка: забытый приоритет", + "Новичок часто пишет `(price + delivery) * count`, хотя доставка должна добавляться один раз после стоимости товаров. Такая ошибка не вызывает красного сообщения: программа работает, но число неверное. Перед кодом проговори расчёт словами и проверь его на маленьких числах.", + "price = 100\ncount = 2\ndelivery = 50\nprint((price * count) + delivery)\n# Вывод:\n# 250", + "Сравни формулу с условием, а не только с синтаксисом.", + ), + ], + [ + _choice( + "operators-arithmetic", + "Какая формула считает стоимость товаров и один раз добавляет доставку?", + [ + "(price + delivery) * count", + "price * count + delivery", + "price + count * delivery", + ], + "price * count + delivery", + "Сначала считается цена всех товаров, затем один раз прибавляется доставка.", + ), + _term( + "operators-arithmetic", + "Как называется сочетание значений и операторов, которое даёт результат?", + ["выражение", "арифметическое выражение"], + "Выражение вычисляется и превращается в одно значение.", + ), + _code( + "operators-arithmetic", + "Напиши `calculate_total(price, count, delivery)`: верни стоимость `count` товаров по цене `price` и один раз добавь `delivery`.", + "def calculate_total(price, count, delivery):\n # верни результат вычисления\n pass\n", + [ + { + "kind": "call", + "call": "calculate_total(120, 3, 50)", + "expected": 410, + }, + { + "kind": "call", + "call": "calculate_total(99, 2, 0)", + "expected": 198, + }, + ], + "def calculate_total(price, count, delivery):\n return price * count + delivery\n", + "Верно: умножение считает товары, сложение добавляет доставку.", + "Формула: цена * количество + доставка.", + ), + ], + ), + _lesson( + "operators-integer-division", + "operators", + 14, + "Целая часть и остаток", + "Раскладываем величину с помощью // и %", + 12, + [ + _card( + "Деление без дробной части", + "Оператор `//` выполняет целочисленное деление: показывает, сколько полных раз делитель помещается в числе. Например, в 135 секундах помещаются две полные минуты. В отличие от обычного `/`, результат для положительных целых чисел не содержит дробной части.", + "seconds = 135\nminutes = seconds // 60\nprint(minutes)\n# Вывод:\n# 2", + ), + _card( + "Остаток после деления", + "Оператор `%` возвращает остаток — то, что не вошло в полные группы. После двух полных минут от 135 секунд остаётся 15 секунд, поэтому `135 % 60` равно 15. Пара `//` и `%` раскладывает одну величину на крупные единицы и остаток. Тем же способом можно получить часы и минуты или десятки и единицы.", + "seconds = 135\nrest = seconds % 60\nprint(rest)\n# Вывод:\n# 15", + ), + _card( + "Типичная ошибка: перепутать / и //", + "Новичок может написать `135 / 60` и получить `2.25`, хотя задача просит число полных минут. Другая ошибка — взять остаток от неверного делителя, например `% 100` вместо `% 60`. Сначала определи размер одной группы, затем используй один и тот же делитель в `//` и `%`. Для секунд в минуте этот размер равен 60.", + "seconds = 367\nprint(seconds // 60, seconds % 60)\n# Вывод:\n# 6 7", + "Для разложения используй одну пару: value // size и value % size.", + ), + ], + [ + _choice( + "operators-integer-division", + "Какая пара выражений даст полные минуты и оставшиеся секунды?", + [ + "seconds / 60, seconds % 100", + "seconds // 60, seconds % 60", + "seconds % 60, seconds // 100", + ], + "seconds // 60, seconds % 60", + "`//` находит полные минуты, `%` — остаток секунд.", + ), + _term( + "operators-integer-division", + "Как называется результат операции `%`?", + ["остаток", "остаток от деления"], + "Оператор `%` возвращает остаток от деления.", + ), + _code( + "operators-integer-division", + "Напиши `split_time(total_seconds)`, которая возвращает список `[полные_минуты, оставшиеся_секунды]`. Используй `//` и `%`.", + "def split_time(total_seconds):\n # используй // и %\n pass\n", + [ + {"kind": "call", "call": "split_time(135)", "expected": [2, 15]}, + {"kind": "call", "call": "split_time(60)", "expected": [1, 0]}, + {"kind": "call", "call": "split_time(367)", "expected": [6, 7]}, + ], + "def split_time(total_seconds):\n return [total_seconds // 60, total_seconds % 60]\n", + "Готово: время разложено на полные минуты и остаток секунд.", + "Обе операции используют делитель 60.", + ), + ], + ), + _lesson( + "operators-booleans", + "operators", + 15, + "Логические выражения", + "Объединяем условия с and, or и not", + 13, + [ + _card( + "and требует выполнения всех условий", + "Логическое выражение даёт `True` или `False`. Оператор `and` возвращает истину, только когда истинны обе его части. Его читают как «и»: пользователь совершеннолетний и у него есть билет. Если хотя бы одно условие ложно, всё выражение ложно. Так несколько обязательных требований соединяются в одну проверку.", + "age = 20\nhas_ticket = True\nprint(age >= 18 and has_ticket)\n# Вывод:\n# True", + ), + _card( + "Таблица истинности and, or и not", + "Таблица истинности показывает сочетания значений и результат. У `and` результат `True` только в строке `True, True`. У `or` результат `False` только в строке `False, False`, потому что ему достаточно одной истины. Оператор `not` меняет одно значение на противоположное: `not True` даёт `False`, а `not False` — `True`.", + "print(False and False, False or False)\nprint(False and True, False or True)\nprint(True and False, True or False)\nprint(True and True, True or True)\nprint(not False, not True)\n# Вывод:\n# False False\n# False True\n# False True\n# True True\n# True False", + ), + _card( + "Типичная ошибка: неверное отрицание", + "Новичок часто пишет `is_blocked`, хотя пропускать нужно именно не заблокированного человека. Слово `not` должно стоять перед тем условием, которое требуется перевернуть. Ещё одна ошибка — заменить `and` на `or` между обязательными требованиями: тогда хватит одного из них.", + "has_ticket = True\nis_blocked = False\nprint(has_ticket and not is_blocked)\n# Вывод:\n# True", + "Скобками отделяй альтернативы от обязательных условий.", + ), + ], + [ + _choice( + "operators-booleans", + "Когда выражение `a and b` возвращает True?", + [ + "Когда истинно только a", + "Когда истинна хотя бы одна часть", + "Когда истинны обе части", + ], + "Когда истинны обе части", + "`and` требует истинности каждого соединённого условия.", + ), + _term( + "operators-booleans", + "Какой оператор меняет True на False и False на True?", + ["not", "оператор not"], + "`not` означает логическое «не» и переворачивает значение.", + ), + _code( + "operators-booleans", + "Напиши `can_enter(age, has_ticket, with_adult, is_blocked)`. Войти можно совершеннолетнему ИЛИ пришедшему со взрослым, но только с билетом И без блокировки. Используй `and`, `or`, `not`.", + "def can_enter(age, has_ticket, with_adult, is_blocked):\n # объедини четыре условия\n pass\n", + [ + { + "kind": "call", + "call": "can_enter(20, True, False, False)", + "expected": True, + }, + { + "kind": "call", + "call": "can_enter(15, True, True, False)", + "expected": True, + }, + { + "kind": "call", + "call": "can_enter(15, True, False, False)", + "expected": False, + }, + { + "kind": "call", + "call": "can_enter(20, True, False, True)", + "expected": False, + }, + ], + "def can_enter(age, has_ticket, with_adult, is_blocked):\n return (age >= 18 or with_adult) and has_ticket and not is_blocked\n", + "Верно: альтернатива по возрасту соединена с двумя обязательными условиями.", + "Сначала заключи в скобки возраст ИЛИ сопровождение.", + ), + ], + ), + _lesson( + "operators-floating-point", + "operators", + 16, + "Дробные числа", + "Округляем и сравниваем float с учётом погрешности", + 14, + [ + _card( + "float хранит приближённое значение", + "`float` — тип для чисел с дробной частью. Компьютер хранит их в двоичном виде, и некоторые привычные десятичные дроби нельзя записать там точно. Поэтому результат вычисления может отличаться на маленькую величину: `0.1 + 0.2` выглядит как 0.30000000000000004. Это не случайная ошибка программы, а погрешность представления.", + "value = 0.1 + 0.2\nprint(value)\n# Вывод:\n# 0.30000000000000004", + ), + _card( + "round округляет до нужного разряда", + "Функция `round(number, digits)` возвращает число, округлённое до `digits` знаков после точки. Например, `round(value, 2)` оставляет два знака. Округление удобно для понятного вывода и для учебного сравнения результатов с одинаковой точностью.", + "value = 0.1 + 0.2\nprint(round(value, 2))\n# Вывод:\n# 0.3", + ), + _card( + "Типичная ошибка: сравнить float напрямую", + "Новичок ожидает, что `0.1 + 0.2 == 0.3` даст `True`, но крошечная погрешность делает значения разными. Для задачи с заданным числом знаков сначала округли обе стороны одинаково, затем сравни их. Не округляй только одно число и не используй разное количество знаков. Для настоящих финансов позже понадобится специальный тип `Decimal`.", + "left = 0.1 + 0.2\nright = 0.3\nprint(round(left, 2) == round(right, 2))\n# Вывод:\n# True", + "Одинаковая точность у обеих сторон — часть правила.", + ), + ], + [ + _choice( + "operators-floating-point", + "Почему `0.1 + 0.2` может не быть точно равно `0.3`?", + [ + "0.1 и 0.2 нельзя сравнивать", + "Некоторые дроби хранятся приближённо", + "== работает только с целыми числами", + ], + "Некоторые дроби хранятся приближённо", + "Двоичное представление не может точно сохранить каждую десятичную дробь.", + ), + _term( + "operators-floating-point", + "Как называется небольшое отличие сохранённого float от точного значения?", + ["погрешность", "погрешность представления"], + "Погрешность представления появляется из-за приближённого хранения дробей.", + ), + _code( + "operators-floating-point", + "Напиши `almost_equal(left, right, digits)`: округли ОБА числа функцией `round` до `digits` знаков и верни результат их сравнения.", + "def almost_equal(left, right, digits):\n # округли обе стороны перед сравнением\n pass\n", + [ + { + "kind": "call", + "call": "almost_equal(0.1 + 0.2, 0.3, 2)", + "expected": True, + }, + { + "kind": "call", + "call": "almost_equal(1.234, 1.235, 2)", + "expected": False, + }, + ], + "def almost_equal(left, right, digits):\n return round(left, digits) == round(right, digits)\n", + "Верно: обе дроби сравниваются после одинакового округления.", + "Вызови round отдельно для left и right.", + ), + ], + ), + _lesson( + "strings-pro-indexes", + "strings-pro", + 17, + "Индексы и срезы", + "Берём часть строки по границам и шагу", + 11, + [ + _card( + "Индекс указывает один символ", + "Строка — последовательность символов, то есть знаков, расположенных по порядку. Индекс — номер позиции символа; отсчёт начинается с нуля. Поэтому у строки `'python'` символ с индексом 0 — `p`, а с индексом 1 — `y`. Индекс в квадратных скобках возвращает один знак.", + "word = 'python'\nprint(word[0], word[-1])\n# Вывод:\n# p n", + ), + _card( + "Срез возвращает часть строки", + "Срез записывают как `text[start:stop]`. `start` — индекс первого символа, а `stop` — граница, которая уже не входит в результат. Поэтому `[1:4]` берёт позиции 1, 2 и 3. Пропущенная левая граница означает начало строки, пропущенная правая — конец. Срез не меняет исходную строку, а создаёт новую.", + "word = 'python'\nprint(word[1:4])\n# Вывод:\n# yth", + ), + _card( + "Шаг и типичная ошибка границы", + "Третье число в срезе — шаг: `text[start:stop:step]`. Шаг 2 берёт каждый второй символ, а `[::-1]` проходит строку в обратном направлении. Новички часто ожидают увидеть символ с индексом `stop` и получают на один знак меньше. Запомни: левая граница включается, правая — нет. Проверь позиции на коротком слове.", + "word = 'python'\nprint(word[1::2])\n# Вывод:\n# yhn", + "Подпиши индексы 0, 1, 2 над символами, если путаешь границы.", + ), + ], + [ + _choice( + "strings-pro-indexes", + "Что вернёт срез `'python'[1:4]`?", + ["pyt", "yth", "ytho"], + "yth", + "Правая граница 4 не входит: берутся индексы 1, 2 и 3.", + ), + _term( + "strings-pro-indexes", + "Как называется запись `text[start:stop]`?", + ["срез", "срез строки"], + "Срез возвращает новую строку из выбранной части исходной.", + ), + _code( + "strings-pro-indexes", + "Напиши `odd_positions(text)`, которая срезом возвращает символы с индексов 1, 3, 5 и так далее.", + "def odd_positions(text):\n # используй срез с шагом\n pass\n", + [ + {"kind": "call", "call": "odd_positions('python')", "expected": "yhn"}, + {"kind": "call", "call": "odd_positions('abcdefg')", "expected": "bdf"}, + {"kind": "call", "call": "odd_positions('x')", "expected": ""}, + ], + "def odd_positions(text):\n return text[1::2]\n", + "Верно: срез начинается с индекса 1 и идёт с шагом 2.", + "Запись состоит из начала, пустой границы конца и шага 2.", + ), + ], + ), + _lesson( + "strings-pro-search-replace", + "strings-pro", + 18, + "Поиск и замена", + "Проверяем, находим и заменяем части строки", + 12, + [ + _card( + "in отвечает, есть ли фрагмент", + "Оператор `in` проверяет вхождение: встречается ли одна строка внутри другой. Результат всегда логический — `True` или `False`. Проверка учитывает регистр, поэтому `'Py' in 'Python'` истинно, а `'py' in 'Python'` ложно.", + "address = 'https://python.org'\nprint('https://' in address)\n# Вывод:\n# True", + ), + _card( + "find ищет, replace создаёт новую строку", + "Метод `find(fragment)` ищет начало фрагмента и возвращает его индекс. Метод `replace(old, new)` возвращает новую строку, где старый фрагмент заменён новым; исходная строка не меняется. Метод — действие, которое вызывают после точки у значения: `text.find('/')`. Индекс можно использовать как границу среза, пустая строка `''` в `replace` удаляет найденный фрагмент.", + "address = 'https://python.org'\nclean = address.replace('https://', '')\nprint(clean, clean.find('/'))\n# Вывод:\n# python.org -1", + ), + _card( + "Типичная ошибка: забыть про -1 и новое значение", + "Новичок может использовать результат `find` как границу среза, не проверив `-1`: тогда срез отбросит последний символ. Другая ошибка — вызвать `address.replace(...)`, но не сохранить результат; переменная останется прежней. Сначала проверь, найден ли фрагмент, а результат `replace` присвой обратно или новому имени.", + "address = 'https://python.org'\naddress.replace('https://', '')\ntext = 'python.org'\nposition = text.find('/')\nprint(position, text[:position], address)\n# Вывод:\n# -1 python.or https://python.org", + "Проверяй `find` на `-1` и сохраняй результат методов строк.", + ), + ], + [ + _choice( + "strings-pro-search-replace", + "Что вернёт `text.find('x')`, если символа `x` в строке нет?", + ["0", "False", "-1"], + "-1", + "`find` использует -1 как признак отсутствия фрагмента.", + ), + _term( + "strings-pro-search-replace", + "Какой метод заменяет один фрагмент строки другим?", + ["replace", "replace()", "метод replace"], + "`replace(old, new)` возвращает строку с заменами.", + ), + _code( + "strings-pro-search-replace", + "Напиши `get_domain(address)`. Если есть `https://`, удали его через `replace`. Через `find` найди первый `/` после домена и верни часть до него; если `/` нет, верни всю строку. Используй также `in`.", + "def get_domain(address):\n # используй in, replace и find\n pass\n", + [ + { + "kind": "call", + "call": "get_domain('https://python.org/learn')", + "expected": "python.org", + }, + { + "kind": "call", + "call": "get_domain('python.org')", + "expected": "python.org", + }, + ], + "def get_domain(address):\n if 'https://' in address:\n address = address.replace('https://', '')\n slash = address.find('/')\n if slash != -1:\n return address[:slash]\n return address\n", + "Верно: протокол удалён, а путь отделён по позиции косой черты.", + "После удаления протокола сохрани индекс `/` в переменной.", + ), + ], + ), + _lesson( + "strings-pro-split-join", + "strings-pro", + 19, + "Разделение и склейка", + "Преобразуем строку в список и обратно", + 13, + [ + _card( + "split делит строку на части", + "Метод `split(separator)` разделяет строку в местах, где встречается указанный разделитель, и возвращает список частей. Разделитель — строка, которая отмечает границу, например запятая или `/`. Сам разделитель в части не попадает. Если вызвать `split()` без аргумента, Python разделит текст по пробельным символам.", + "colors = 'red,green,blue'\nprint(colors.split(','))\n# Вывод:\n# ['red', 'green', 'blue']", + ), + _card( + "join собирает строки", + "Метод `join(parts)` выполняет обратную работу: соединяет строки из последовательности и вставляет между ними выбранный разделитель. Разделитель записывается перед точкой, поэтому правильно `' | '.join(parts)`. Результат — одна новая строка.", + "parts = ['home', 'user', 'docs']\nprint(' > '.join(parts))\n# Вывод:\n# home > user > docs", + ), + _card( + "Типичная ошибка: перепутать стороны", + "Новичок пишет `parts.join(',')`, будто список должен вызвать метод. Но `join` принадлежит строке-разделителю, поэтому слева стоит `','`, а список передаётся в скобках. Другая ошибка — ожидать, что `split` изменит исходный текст. Оба метода возвращают новые значения: сохрани список после `split` и строку после `join`.", + "path = 'courses/python/basics'\nparts = path.split('/')\nprint(' -> '.join(parts))\n# Вывод:\n# courses -> python -> basics", + "Читай запись как «разделителем соединить части».", + ), + ], + [ + _choice( + "strings-pro-split-join", + "Какая запись соединит `['a', 'b']` строкой `a-b`?", + ["['a', 'b'].join('-')", "'-'.join(['a', 'b'])", "'-'.split(['a', 'b'])"], + "'-'.join(['a', 'b'])", + "Разделитель вызывает join, а список строк передаётся в скобках.", + ), + _term( + "strings-pro-split-join", + "Какой метод разделяет строку и возвращает список?", + ["split", "split()", "метод split"], + "`split` делит строку по выбранному разделителю.", + ), + _code( + "strings-pro-split-join", + "Напиши `route(path)`: раздели строку `path` по `/`, затем соедини части через ` -> `.", + "def route(path):\n # сначала split, затем join\n pass\n", + [ + { + "kind": "call", + "call": "route('courses/python/basics')", + "expected": "courses -> python -> basics", + }, + {"kind": "call", "call": "route('one')", "expected": "one"}, + ], + "def route(path):\n parts = path.split('/')\n return ' -> '.join(parts)\n", + "Верно: строка стала списком частей, затем снова строкой.", + "Сохрани результат split и передай его методу join.", + ), + ], + ), + _lesson( + "strings-pro-formatting", + "strings-pro", + 20, + "Форматирование текста", + "Выравниваем значения внутри f-строк", + 14, + [ + _card( + "f-строка вставляет значение", + "f-строка — строка с буквой `f` перед кавычками, в которую можно вставить значение через фигурные скобки. Выражение внутри скобок вычисляется, а его результат превращается в текст. Это понятнее, чем складывать строки знаком `+`, особенно когда среди значений есть числа.", + "name = 'Лена'\nscore = 42\nprint(f'{name}: {score} XP')\n# Вывод:\n# Лена: 42 XP", + ), + _card( + "Спецификатор задаёт ширину", + "После значения внутри фигурных скобок можно поставить двоеточие и правило формата. Запись `:>5` выделяет поле шириной пять знаков и прижимает значение вправо; свободные места заполняются пробелами. Запись `:<10` прижимает значение влево. Одинаковая ширина полей помогает строить ровные строки отчёта или таблицы.", + "score = 42\nprint(f'|{score:>5}|')\n# Вывод:\n# | 42|", + ), + _card( + "Типичная ошибка: двоеточие снаружи", + "Новичок пишет `f'{score}:>5'` и получает обычный текст `:>5` после числа. Спецификатор должен находиться внутри тех же фигурных скобок: `{score:>5}`. Ширина — минимальное число позиций, а не обрезка: длинное значение Python покажет полностью. Для проверки окружи поле символами `|`, тогда пробелы станут заметны.", + "name = 'Ира'\nprint(f'|{name:<8}|')\n# Вывод:\n# |Ира |", + "Границы `|` помогают увидеть невидимые пробелы.", + ), + ], + [ + _choice( + "strings-pro-formatting", + "Какая запись прижмёт `score` вправо в поле шириной 5?", + ["f'{score}>5'", "f'{score:>5}'", "f'{score:<5}'"], + "f'{score:>5}'", + "Знак > означает выравнивание вправо, число 5 — ширину поля.", + ), + _term( + "strings-pro-formatting", + "Как называется часть `:>5` внутри фигурных скобок f-строки?", + ["спецификатор", "спецификатор формата", "форматный спецификатор"], + "Спецификатор формата управляет шириной и выравниванием значения.", + ), + _code( + "strings-pro-formatting", + "Напиши `score_row(name, score)`, которая возвращает f-строку: имя в поле шириной 10 слева, затем `|`, затем score в поле шириной 5 справа.", + "def score_row(name, score):\n # используй :<10 и :>5\n pass\n", + [ + { + "kind": "call", + "call": "score_row('Лена', 42)", + "expected": "Лена | 42", + }, + { + "kind": "call", + "call": "score_row('Максим', 7)", + "expected": "Максим | 7", + }, + ], + "def score_row(name, score):\n return f'{name:<10}|{score:>5}'\n", + "Верно: оба столбца имеют заданную ширину и направление.", + "Оба спецификатора находятся внутри фигурных скобок.", + ), + ], + ), + _lesson( + "tuples-slices-tuples", + "tuples-slices", + 21, + "Кортежи", + "Храним фиксированную группу значений", + 11, + [ + _card( + "Кортеж — последовательность значений", + "Кортеж — упорядоченная группа значений, которую обычно записывают через запятые в круглых скобках. Упорядоченная означает, что у каждого элемента есть постоянная позиция. В одном кортеже могут лежать значения разных типов, например имя и число.", + "point = (10, 20)\nprint(point)\n# Вывод:\n# (10, 20)", + ), + _card( + "Кортеж нельзя изменить", + "Кортеж — неизменяемая последовательность: после создания нельзя заменить, добавить или удалить его элемент. Это полезно, когда группа значений должна оставаться цельной и случайное изменение нежелательно. Читать элементы по индексам можно так же, как у строки или списка.", + "color = (255, 128, 0)\nprint(color[1])\n# Вывод:\n# 128", + ), + _card( + "Типичная ошибка: один элемент без запятой", + "Скобки сами по себе не создают кортеж: `(42)` — это просто число 42 в скобках. Кортеж из одного элемента обязательно содержит запятую: `(42,)`. Новички также пытаются присвоить `point[0] = 5` и получают ошибку, потому что кортеж неизменяем. Выбирай список для меняющегося набора и кортеж для фиксированной записи.", + "single = (42,)\nprint(single, len(single))\n# Вывод:\n# (42,) 1", + "Кортеж создаёт запятая; скобки лишь делают запись яснее.", + ), + ], + [ + _choice( + "tuples-slices-tuples", + "Какая запись создаёт кортеж из одного значения 42?", + ["(42)", "[42]", "(42,)"], + "(42,)", + "Для кортежа из одного элемента нужна завершающая запятая.", + ), + _term( + "tuples-slices-tuples", + "Как называется неизменяемая упорядоченная группа значений?", + ["кортеж", "tuple"], + "Кортеж хранит значения по порядку и не позволяет заменить элементы.", + ), + _code( + "tuples-slices-tuples", + "Напиши `make_rgb(red, green, blue)`, которая возвращает кортеж из трёх значений в том же порядке.", + "def make_rgb(red, green, blue):\n # создай кортеж\n pass\n", + [ + { + "kind": "call", + "call": "make_rgb(1, 2, 3).__class__.__name__", + "expected": "tuple", + }, + { + "kind": "call", + "call": "list(make_rgb(255, 128, 0))", + "expected": [255, 128, 0], + }, + { + "kind": "call", + "call": "list(make_rgb(0, 0, 0))", + "expected": [0, 0, 0], + }, + ], + "def make_rgb(red, green, blue):\n return red, green, blue\n", + "Верно: функция возвращает фиксированную тройку компонентов цвета.", + "Перечисли три значения через запятые.", + ), + ], + ), + _lesson( + "tuples-slices-unpacking", + "tuples-slices", + 22, + "Распаковка", + "Присваиваем элементы нескольким именам сразу", + 12, + [ + _card( + "Распаковка связывает позиции с именами", + "Распаковка — присваивание элементов последовательности нескольким переменным за один шаг. В записи `x, y = point` первое имя получает первый элемент, второе — второй. Имена показывают смысл позиций лучше, чем повторяющиеся индексы `point[0]` и `point[1]`.", + "point = (10, 20)\nx, y = point\nprint(x, y)\n# Вывод:\n# 10 20", + ), + _card( + "Количество должно совпадать", + "При обычной распаковке число переменных слева должно совпадать с числом элементов справа. Два имени принимают ровно два значения, три имени — три. Если количества различаются, Python сообщает об ошибке. Такое строгое правило полезно: оно сразу показывает, что форма данных неожиданно изменилась, вместо тихого пропуска значения.", + "profile = ('Лена', 3, True)\nname, level, ready = profile\nprint(name, level, ready)\n# Вывод:\n# Лена 3 True", + ), + _card( + "Типичная ошибка: перепутать порядок", + "Распаковка идёт по позициям, а не по смыслу имён. Если написать `level, name = ('Лена', 3)`, переменные получат неподходящие значения, но самой ошибки Python не заметит. Сверяй порядок имён с описанием данных. Другая частая ошибка — указать слишком мало переменных. Для начала используй точное число понятных имён.", + "user = ('Ира', 5)\nname, level = user\nprint(f'{name}: уровень {level}')\n# Вывод:\n# Ира: уровень 5", + "Названия слева записывай в том же смысловом порядке, что и данные.", + ), + ], + [ + _choice( + "tuples-slices-unpacking", + "Что получит `name` после `name, level = ('Лена', 3)`?", + ["3", "'Лена'", "('Лена', 3)"], + "'Лена'", + "Первое имя получает первый элемент последовательности.", + ), + _term( + "tuples-slices-unpacking", + "Как называется присваивание `x, y = point`?", + ["распаковка", "распаковка кортежа", "множественное присваивание"], + "Распаковка раздаёт элементы последовательности отдельным именам.", + ), + _code( + "tuples-slices-unpacking", + "Напиши `describe(profile)`. Распакуй `(name, level, is_ready)` в три переменные. Верни строку `: , готов` или `: , не готов`. Решение через индексы не принимается.", + "def describe(profile):\n # обязательна распаковка в три переменные\n pass\n", + [ + { + "kind": "source", + "requires": "unpacking", + "name": "profile", + "function": "describe", + }, + { + "kind": "call", + "call": "describe(('Лена', 3, True))", + "expected": "Лена: 3, готов", + }, + { + "kind": "call", + "call": "describe(('Игорь', 1, False))", + "expected": "Игорь: 1, не готов", + }, + ], + "def describe(profile):\n name, level, is_ready = profile\n if is_ready:\n status = 'готов'\n else:\n status = 'не готов'\n return f'{name}: {level}, {status}'\n", + "Верно: три позиции получили понятные имена через распаковку.", + "Первая строка функции: name, level, is_ready = profile.", + ), + ], + ), + _lesson( + "tuples-slices-slices", + "tuples-slices", + 23, + "Срезы последовательностей", + "Выбираем часть списка, не меняя исходный", + 13, + [ + _card( + "Срез списка создаёт новый список", + "Список — изменяемая последовательность значений. Срез `items[start:stop]` выбирает элементы от левой границы до правой, не включая правую. В отличие от обращения по одному индексу, результат среза всегда является новым списком. Исходный список остаётся прежним, поэтому выбранную часть можно менять отдельно.", + "items = [10, 20, 30, 40]\nprint(items[1:3])\n# Вывод:\n# [20, 30]", + ), + _card( + "Пустая граница означает край", + "Если не указать `start`, срез начинается с первого элемента; если не указать `stop`, продолжается до конца. Запись `items[:]` копирует весь список. Отрицательные границы считают позиции с конца: `items[-2:]` берёт два последних элемента.", + "items = ['a', 'b', 'c', 'd']\nprint(items[-2:])\n# Вывод:\n# ['c', 'd']", + ), + _card( + "Шаг и типичная ошибка с копией", + "Третий параметр среза задаёт шаг. `items[::2]` выбирает элементы с индексами 0, 2, 4, а `items[::-1]` создаёт список в обратном порядке. Новичок может написать `copy = items` и считать, что получил копию, но оба имени укажут на один список. Для независимой поверхностной копии используй полный срез `items[:]`.", + "items = [0, 1, 2, 3, 4, 5]\nprint(items[::2])\n# Вывод:\n# [0, 2, 4]", + "Присваивание не копирует список; срез создаёт новый.", + ), + ], + [ + _choice( + "tuples-slices-slices", + "Какой срез выберет элементы с индексами 0, 2, 4?", + ["items[1::2]", "items[::2]", "items[2:]"], + "items[::2]", + "Пустые границы берут весь список, шаг 2 пропускает каждый второй элемент.", + ), + _term( + "tuples-slices-slices", + "Как называется третье число в записи `items[start:stop:2]`?", + ["шаг", "шаг среза"], + "Шаг определяет расстояние между выбранными элементами.", + ), + _code( + "tuples-slices-slices", + "Напиши `every_second(items)`, которая срезом возвращает новый список с элементами на индексах 0, 2, 4 и далее.", + "def every_second(items):\n # используй срез списка\n pass\n", + [ + { + "kind": "call", + "call": "every_second([0, 1, 2, 3, 4, 5])", + "expected": [0, 2, 4], + }, + { + "kind": "call", + "call": "every_second(['a', 'b', 'c'])", + "expected": ["a", "c"], + }, + {"kind": "call", "call": "every_second([])", "expected": []}, + ], + "def every_second(items):\n return items[::2]\n", + "Верно: срез с шагом 2 создаёт новый список нужных элементов.", + "Оставь обе границы пустыми и укажи шаг 2.", + ), + ], + ), + _lesson( + "tuples-slices-zip-enumerate", + "tuples-slices", + 24, + "zip и enumerate", + "Соединяем последовательности и нумеруем пары", + 14, + [ + _card( + "zip соединяет элементы по позициям", + "`zip(first, second)` соединяет элементы по позициям и возвращает пары-кортежи: первый с первым, второй со вторым. Кортежи вы уже знаете из урока 21, поэтому в цикле пару удобно распаковать в два имени.", + "names = ['Лена', 'Ира']\nscores = [8, 10]\nprint(list(zip(names, scores)))\n# Вывод:\n# [('Лена', 8), ('Ира', 10)]", + ), + _card( + "enumerate добавляет индекс", + "Функция `enumerate(sequence)` выдаёт для каждого элемента пару: его индекс и само значение. По умолчанию индексы начинаются с нуля. Если в отчёте номера должны начинаться с единицы, на этом этапе достаточно вывести `index + 1`. `enumerate` заменяет ручную переменную-счётчик, которую легко забыть увеличить.", + "names = ['Лена', 'Ира']\nfor index, name in enumerate(names):\n print(index, name)\n# Вывод:\n# 0 Лена\n# 1 Ира", + ), + _card( + "Типичная ошибка: забыть +1 к индексу", + "`enumerate` начинает нумерацию с нуля. Поэтому в отчёте первый ученик без `+ 1` получит номер 0, хотя нужен номер 1. `start=1` вы ещё не используете: прибавь единицу при выводе.", + "names = ['Лена', 'Ира']\nfor index, name in enumerate(names):\n print(index + 1, name)\n# Вывод:\n# 1 Лена\n# 2 Ира", + "Если номер виден пользователю, выводи `index + 1`; для самого списка индекс остаётся с нуля.", + ), + ], + [ + _choice( + "tuples-slices-zip-enumerate", + "Что даёт `enumerate(names)` на каждом шаге цикла?", + ["Только имя", "Индекс и имя", "Два соседних имени"], + "Индекс и имя", + "`enumerate` добавляет индекс к каждому элементу последовательности.", + ), + _term( + "tuples-slices-zip-enumerate", + "Какая функция объединяет элементы нескольких последовательностей по позициям?", + ["zip", "zip()", "функция zip"], + "`zip` создаёт пары первых, вторых и следующих элементов.", + ), + _code( + "tuples-slices-zip-enumerate", + "Напиши `numbered_scores(names, scores)`. Обойди `enumerate(zip(names, scores))` и верни список строк вида `1. Лена: 8`. Не используй `start=1`: прибавь единицу к индексу.", + "def numbered_scores(names, scores):\n result = []\n # используй enumerate и zip\n return result\n", + [ + { + "kind": "call", + "call": "numbered_scores(['Лена', 'Ира'], [8, 10])", + "expected": ["1. Лена: 8", "2. Ира: 10"], + }, + { + "kind": "call", + "call": "numbered_scores(['Макс'], [7, 9])", + "expected": ["1. Макс: 7"], + }, + ], + "def numbered_scores(names, scores):\n result = []\n for index, (name, score) in enumerate(zip(names, scores)):\n result.append(f'{index + 1}. {name}: {score}')\n return result\n", + "Верно: zip связал данные, enumerate добавил номер.", + "В цикле распакуй index и пару (name, score).", + ), + ], + ), + _lesson( + "lists-pro-list-methods", + "lists-pro", + 25, + "Методы списка", + "Добавляем, вставляем и удаляем элементы", + 11, + [ + _card( + "append и extend добавляют по-разному", + "Метод списка — действие, которое вызывают после точки и которое меняет сам список. `append(value)` добавляет в конец один элемент. `extend(sequence)` добавляет по очереди все элементы другой последовательности. Если передать список в `append`, он станет одним вложенным элементом; `extend` добавит его содержимое отдельно.", + "tasks = ['урок']\ntasks.append('тест')\ntasks.extend(['перерыв', 'проект'])\nprint(tasks)\n# Вывод:\n# ['урок', 'тест', 'перерыв', 'проект']", + ), + _card( + "insert выбирает позицию, pop удаляет", + "`insert(index, value)` вставляет значение перед указанным индексом и сдвигает остальные элементы вправо. `pop()` удаляет последний элемент и одновременно возвращает его; `pop(index)` делает то же для выбранной позиции. Возвращённое значение можно сохранить. Эти методы полезны, когда порядок списка меняется по шагам.", + "queue = ['обычная', 'позже']\nqueue.insert(0, 'срочная')\nremoved = queue.pop()\nprint(queue, removed)\n# Вывод:\n# ['срочная', 'обычная'] позже", + ), + _card( + "Типичная ошибка: присвоить результат", + "Методы `append`, `extend` и `insert` меняют список на месте и возвращают `None`. Новичок пишет `tasks = tasks.append('тест')`, после чего в `tasks` оказывается `None` и список потерян под этим именем. Вызывай меняющий метод отдельной строкой.", + "tasks = ['код']\ntasks.append('проверка')\nremoved = tasks.pop()\nprint(tasks, removed)\n# Вывод:\n# ['код'] проверка", + "Сначала спроси: метод возвращает новое значение или меняет объект на месте?", + ), + ], + [ + _choice( + "lists-pro-list-methods", + "Как добавить элементы `['b', 'c']` в список как два отдельных элемента?", + [ + "items.append(['b', 'c'])", + "items.extend(['b', 'c'])", + "items.insert(['b', 'c'])", + ], + "items.extend(['b', 'c'])", + "`extend` добавляет каждый элемент переданной последовательности.", + ), + _term( + "lists-pro-list-methods", + "Какой метод удаляет элемент списка и возвращает его?", + ["pop", "pop()", "метод pop"], + "`pop` возвращает удалённое значение, поэтому его можно сохранить.", + ), + _code( + "lists-pro-list-methods", + "Напиши `update_plan(tasks, urgent, extras)`. Добавь `'проверка'` через append, добавь все `extras` через extend, вставь `urgent` в начало через insert, затем удали последний элемент через pop. Верни кортеж `(tasks, removed)`.", + "def update_plan(tasks, urgent, extras):\n # используй append, extend, insert и pop\n pass\n", + [ + { + "kind": "call", + "call": "list(update_plan(['урок'], 'срочно', ['тест', 'отдых']))", + "expected": [["срочно", "урок", "проверка", "тест"], "отдых"], + }, + { + "kind": "call", + "call": "list(update_plan([], 'старт', ['финиш']))", + "expected": [["старт", "проверка"], "финиш"], + }, + ], + "def update_plan(tasks, urgent, extras):\n tasks.append('проверка')\n tasks.extend(extras)\n tasks.insert(0, urgent)\n removed = tasks.pop()\n return tasks, removed\n", + "Верно: четыре метода последовательно изменили список.", + "Каждый меняющий метод вызови отдельной строкой; результат нужен только от pop.", + ), + ], + ), +] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4449f94..63f1eae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,7 +21,7 @@ flowchart LR ### Контент -`content.py` содержит базовые 12 уроков, `extended_curriculum.py` добавляет 108 уроков через структурированный каталог. Такой подход даёт полную программу, не размазывая сотни почти одинаковых структур по роутам. +`content.py` содержит базовые 12 уроков, `lessons_13_25.py` — рукописный переходный блок, а `extended_curriculum.py` генерирует уроки 26–120 через структурированный каталог. Такой подход оставляет сложный для новичка участок подробным, не размазывая остальные однотипные структуры по роутам. ### Прогресс diff --git a/tests/test_api.py b/tests/test_api.py index ae50616..612dc09 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -75,7 +75,10 @@ def test_extended_course_unlocks_after_foundation() -> None: "/api/code/check", json={ "question_id": "operators-arithmetic-code", - "answer": "def increment(value):\n return value + 1\n", + "answer": ( + "def calculate_total(price, count, delivery):\n" + " return price * count + delivery\n" + ), }, ) assert code_check.status_code == 200 diff --git a/tests/test_curriculum.py b/tests/test_curriculum.py index 5b182d3..a5b689e 100644 --- a/tests/test_curriculum.py +++ b/tests/test_curriculum.py @@ -1,4 +1,23 @@ from app.content import EXAMS, LESSONS, MODULES, QUESTION_BY_ID +from app.evaluator import evaluate +from app.extended_curriculum import EXTRA_LESSONS +from app.lessons_13_25 import LESSONS_13_25 + +LESSONS_13_25_IDENTITY = [ + ("operators-arithmetic", "operators", 13), + ("operators-integer-division", "operators", 14), + ("operators-booleans", "operators", 15), + ("operators-floating-point", "operators", 16), + ("strings-pro-indexes", "strings-pro", 17), + ("strings-pro-search-replace", "strings-pro", 18), + ("strings-pro-split-join", "strings-pro", 19), + ("strings-pro-formatting", "strings-pro", 20), + ("tuples-slices-tuples", "tuples-slices", 21), + ("tuples-slices-unpacking", "tuples-slices", 22), + ("tuples-slices-slices", "tuples-slices", 23), + ("tuples-slices-zip-enumerate", "tuples-slices", 24), + ("lists-pro-list-methods", "lists-pro", 25), +] def test_full_course_has_120_progressive_lessons() -> None: @@ -25,3 +44,37 @@ def test_every_module_has_a_valid_exam() -> None: for exam in EXAMS.values(): assert len(exam["question_ids"]) >= 4 assert all(question_id in QUESTION_BY_ID for question_id in exam["question_ids"]) + + +def test_lessons_13_25_are_handwritten_and_keep_their_identity() -> None: + assert [ + (lesson["id"], lesson["module_id"], lesson["order"]) for lesson in LESSONS_13_25 + ] == LESSONS_13_25_IDENTITY + assert [lesson["order"] for lesson in EXTRA_LESSONS] == list(range(26, 121)) + assert LESSONS[12:25] == LESSONS_13_25 + + +def test_lessons_13_25_have_beginner_theory_and_three_questions() -> None: + for lesson in LESSONS_13_25: + cards = lesson["theory"] + assert len(cards) == 3 + assert len({card["title"] for card in cards}) == 3 + assert len({card["example"] for card in cards}) == 3 + assert 600 <= sum(len(card["text"]) for card in cards) <= 900 + assert all("# Вывод:" in card["example"] for card in cards) + assert any( + "ошиб" in (card["title"] + card["text"] + card["tip"]).casefold() for card in cards + ) + + questions = lesson["questions"] + assert [question["kind"] for question in questions] == ["choice", "input", "code"] + assert isinstance(questions[1]["answers"], list) + assert questions[1]["answers"] + assert all(isinstance(answer, str) and answer for answer in questions[1]["answers"]) + + +def test_reference_solutions_for_lessons_13_25_pass_the_runner() -> None: + for lesson in LESSONS_13_25: + code_question = lesson["questions"][2] + result = evaluate(code_question, code_question["reference"]) + assert result["correct"] is True, (lesson["order"], result) diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py index dab9b4d..c5821d5 100644 --- a/tests/test_evaluator.py +++ b/tests/test_evaluator.py @@ -26,3 +26,75 @@ def test_code_runner_blocks_imports() -> None: def test_choice_answer_is_normalized() -> None: question = {"kind": "choice", "answer": "Bool", "explanation": "ok"} assert evaluate(question, " bool ")["correct"] is True + + +def test_code_runner_can_require_unpacking_assignment() -> None: + tests = [ + {"kind": "source", "requires": "unpacking", "name": "profile"}, + {"kind": "call", "call": "describe(('Лена', 3, True))", "expected": "Лена: 3, готов"}, + ] + indexed = ( + "def describe(profile):\n" + " status = 'готов' if profile[2] else 'не готов'\n" + " return f'{profile[0]}: {profile[1]}, {status}'\n" + ) + unpacked = ( + "def describe(profile):\n" + " name, level, is_ready = profile\n" + " status = 'готов' if is_ready else 'не готов'\n" + " return f'{name}: {level}, {status}'\n" + ) + bypass = ( + "def describe(profile):\n" + " unused, also_unused = 1, 2\n" + " status = 'готов' if profile[2] else 'не готов'\n" + " return f'{profile[0]}: {profile[1]}, {status}'\n" + ) + alias_bypass = ( + "def describe(profile):\n" + " name, level, is_ready = profile\n" + " alias = profile\n" + " status = 'готов' if alias[2] else 'не готов'\n" + " return f'{alias[0]}: {alias[1]}, {status}'\n" + ) + + rejected = run_code(indexed, tests) + accepted = run_code(unpacked, tests) + bypassed = run_code(bypass, tests) + alias_bypassed = run_code(alias_bypass, tests) + + assert rejected["correct"] is False + assert "распаков" in rejected["message"].casefold() + assert bypassed["correct"] is False + assert alias_bypassed["correct"] is False + assert accepted["correct"] is True + + +def test_code_runner_unpacking_restricted_to_function() -> None: + tests = [ + { + "kind": "source", + "requires": "unpacking", + "name": "profile", + "function": "describe", + }, + {"kind": "call", "call": "describe(('Лена', 3, True))", "expected": "Лена: 3, готов"}, + ] + bypass = ( + "def unused(profile):\n" + " name, level, is_ready = profile\n" + "\n" + "def describe(profile):\n" + " values = dict(enumerate(profile))\n" + " status = 'готов' if values.get(2) else 'не готов'\n" + ' return f"{values.get(0)}: {values.get(1)}, {status}"\n' + ) + unpacked = ( + "def describe(profile):\n" + " name, level, is_ready = profile\n" + " status = 'готов' if is_ready else 'не готов'\n" + " return f'{name}: {level}, {status}'\n" + ) + + assert run_code(bypass, tests)["correct"] is False + assert run_code(unpacked, tests)["correct"] is True