From e0dd7da71a5a62b6c0c6d7c988499e83e1eb1425 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:02:57 +0300 Subject: [PATCH 1/3] feat: code task required for lesson completion (P0.4) --- app/content.py | 41 +++++++++++++------------------ app/main.py | 15 ++++++++++-- tests/test_api.py | 53 +++++++++++++++++++++++++++++++++++++++++ tests/test_evaluator.py | 7 ++++++ 4 files changed, 89 insertions(+), 27 deletions(-) diff --git a/app/content.py b/app/content.py index 256338a..c3d27ca 100644 --- a/app/content.py +++ b/app/content.py @@ -285,20 +285,17 @@ def lesson( ), text( "if-result", - "Что выведет `if 3 > 5: print('да') else: print('нет')`?", + "Что выведет этот код?\n\nif 3 > 5:\n print('да')\nelse:\n print('нет')", ["нет"], "3 не больше 5, поэтому выполняется ветка else.", ), code( "if-code", - "Напиши `is_adult(age)`, возвращающую True при возрасте 18 и больше.", - "def is_adult(age):\n # твой код\n pass\n", - [ - {"kind": "call", "call": "is_adult(18)", "expected": True}, - {"kind": "call", "call": "is_adult(17)", "expected": False}, - ], - "Условие внутри функции помогает вернуть нужный логический результат.", - "Сравни age с 18 и верни результат сравнения либо используй if/else.", + "Дано `temperature = 22`. Если температура не меньше 25, выведи «Надень шорты», иначе — «Возьми куртку».", + "temperature = 22\n# напиши условие и выведи подходящую одежду\n", + [{"kind": "stdout", "expected": "Возьми куртку"}], + "22 меньше 25, поэтому выполняется ветка else и выводится «Возьми куртку».", + "Сравни temperature с 25 через >=. В if выведи «Надень шорты», в else — «Возьми куртку».", ), ], ), @@ -338,14 +335,11 @@ def lesson( text("for-sum", "Чему равна сумма чисел в `range(1, 4)`?", ["6"], "Это 1 + 2 + 3 = 6."), code( "for-code", - "Напиши `sum_to(number)`, возвращающую сумму от 1 до number включительно.", - "def sum_to(number):\n # твой код\n pass\n", - [ - {"kind": "call", "call": "sum_to(1)", "expected": 1}, - {"kind": "call", "call": "sum_to(5)", "expected": 15}, - ], - "Цикл избавляет от ручного сложения каждого числа.", - "Создай total = 0 и пройди range(1, number + 1).", + "Выведи числа от 1 до 5, каждое с новой строки.", + "for number in range(1, 6):\n # выведи number\n", + [{"kind": "stdout", "expected": "1\n2\n3\n4\n5"}], + "range(1, 6) даёт числа от 1 до 5: верхняя граница не включается.", + "В теле цикла вызови print(number).", ), ], ), @@ -391,14 +385,11 @@ def lesson( ), code( "while-code", - "Напиши `countdown(start)`, возвращающую список от start до 1 через while.", - "def countdown(start):\n # твой код\n pass\n", - [ - {"kind": "call", "call": "countdown(1)", "expected": [1]}, - {"kind": "call", "call": "countdown(4)", "expected": [4, 3, 2, 1]}, - ], - "while хорош, когда счётчик меняется до достижения границы.", - "Начни с result = [] и уменьшай start после добавления в список.", + "Дано `start = 5`. Выведи обратный отсчёт до 1, каждое число с новой строки, затем выведи «Пуск!».", + "start = 5\nwhile start > 0:\n # выведи start и уменьши его\n# выведи «Пуск!»\n", + [{"kind": "stdout", "expected": "5\n4\n3\n2\n1\nПуск!"}], + "while работает, пока start больше нуля: после каждого вывода уменьши start на 1, а затем выведи «Пуск!».", + "В цикле напиши print(start) и start -= 1. После цикла — print('Пуск!').", ), ], ), diff --git a/app/main.py b/app/main.py index 147ba52..77fbff2 100644 --- a/app/main.py +++ b/app/main.py @@ -192,7 +192,13 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict: question_ids = {question["id"] for question in lesson["questions"]} results, correct_count = grade_answers(submission.answers, question_ids) total = len(question_ids) - passed = correct_count >= 2 + code_question_id = next( + question["id"] for question in lesson["questions"] if question["kind"] == "code" + ) + code_correct = next( + item["correct"] for item in results if item["question_id"] == code_question_id + ) + passed = code_correct and correct_count >= 2 gained = 0 if passed: gained, _ = save_lesson(lesson_id, correct_count, total, lesson["xp"]) @@ -200,11 +206,16 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict: "passed": passed, "correct_count": correct_count, "total_count": total, + "code_correct": code_correct, "xp_gained": gained, "results": results, "message": "Урок пройден! Новый урок уже открыт." if passed - else "Нужно минимум 2 верных ответа из 3. Попробуй ещё раз — это нормально.", + else ( + "Для зачёта нужно решить задачу на код" + if correct_count >= 2 and not code_correct + else "Нужно минимум 2 верных ответа из 3. Попробуй ещё раз — это нормально." + ), } diff --git a/tests/test_api.py b/tests/test_api.py index 7595a19..87f484a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -40,6 +40,37 @@ def test_lesson_unlocking_and_progression() -> None: def test_extended_course_unlocks_after_foundation() -> None: with TestClient(app) as client: client.post("/api/reset") + + +def test_lesson_requires_correct_code_for_completion() -> None: + with TestClient(app) as client: + client.post("/api/reset") + two_without_code = [ + {"question_id": "hello-print", "answer": "print('Привет')"}, + {"question_id": "hello-string", "answer": "строка"}, + {"question_id": "hello-code", "answer": ""}, + ] + response = client.post("/api/lessons/hello/submit", json={"answers": two_without_code}) + assert response.json()["passed"] is False + assert response.json()["message"] == "Для зачёта нужно решить задачу на код" + + code_and_one_other = [ + {"question_id": "hello-print", "answer": "print('неверно')"}, + {"question_id": "hello-string", "answer": "строка"}, + {"question_id": "hello-code", "answer": "print('Я начинаю путь в Python!')"}, + ] + response = client.post("/api/lessons/hello/submit", json={"answers": code_and_one_other}) + assert response.json()["passed"] is True + + code_only = [ + {"question_id": "hello-print", "answer": "неверно"}, + {"question_id": "hello-string", "answer": "неверно"}, + {"question_id": "hello-code", "answer": "print('Я начинаю путь в Python!')"}, + ] + client.post("/api/reset") + response = client.post("/api/lessons/hello/submit", json={"answers": code_only}) + assert response.json()["passed"] is False + client.post("/api/reset") for lesson in LESSONS[:12]: save_lesson(lesson["id"], 3, 3, lesson["xp"]) @@ -57,3 +88,25 @@ def test_extended_course_unlocks_after_foundation() -> None: assert code_check.status_code == 200 assert code_check.json()["correct"] is True client.post("/api/reset") + + +def test_code_run_returns_stdout_and_blocks_imports() -> None: + with TestClient(app) as client: + run = client.post("/api/code/run", json={"code": "print('hi')"}) + assert run.status_code == 200 + assert "hi" in run.json()["stdout"] + + blocked = client.post("/api/code/run", json={"code": "import os"}) + assert blocked.status_code == 200 + assert "не поддерживает импорт" in blocked.json()["error"] + + +def test_run_code_returns_stdout_and_rejects_imports() -> None: + with TestClient(app) as client: + response = client.post("/api/code/run", json={"code": "print('hi')"}) + assert response.status_code == 200 + assert "hi" in response.json()["stdout"] + + rejected = client.post("/api/code/run", json={"code": "import os"}) + assert rejected.status_code == 200 + assert rejected.json()["error"] diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py index dab9b4d..56c0821 100644 --- a/tests/test_evaluator.py +++ b/tests/test_evaluator.py @@ -23,6 +23,13 @@ def test_code_runner_blocks_imports() -> None: assert "не поддерживает импорт" in result["message"] +def test_code_runner_adds_russian_hint_for_name_error() -> None: + result = run_code("print(missing_name)\n", []) + assert result["correct"] is False + assert "hint_ru" in result + assert "имя" in result["hint_ru"].casefold() + + def test_choice_answer_is_normalized() -> None: question = {"kind": "choice", "answer": "Bool", "explanation": "ok"} assert evaluate(question, " bool ")["correct"] is True From 3648f6f436f036e35daaaa9fc00bb368d1358355 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:17:47 +0300 Subject: [PATCH 2/3] test: drop out-of-scope tests for unimplemented code-run features --- tests/test_api.py | 22 ---------------------- tests/test_evaluator.py | 7 ------- 2 files changed, 29 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 87f484a..2e42a1b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -88,25 +88,3 @@ def test_lesson_requires_correct_code_for_completion() -> None: assert code_check.status_code == 200 assert code_check.json()["correct"] is True client.post("/api/reset") - - -def test_code_run_returns_stdout_and_blocks_imports() -> None: - with TestClient(app) as client: - run = client.post("/api/code/run", json={"code": "print('hi')"}) - assert run.status_code == 200 - assert "hi" in run.json()["stdout"] - - blocked = client.post("/api/code/run", json={"code": "import os"}) - assert blocked.status_code == 200 - assert "не поддерживает импорт" in blocked.json()["error"] - - -def test_run_code_returns_stdout_and_rejects_imports() -> None: - with TestClient(app) as client: - response = client.post("/api/code/run", json={"code": "print('hi')"}) - assert response.status_code == 200 - assert "hi" in response.json()["stdout"] - - rejected = client.post("/api/code/run", json={"code": "import os"}) - assert rejected.status_code == 200 - assert rejected.json()["error"] diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py index 56c0821..dab9b4d 100644 --- a/tests/test_evaluator.py +++ b/tests/test_evaluator.py @@ -23,13 +23,6 @@ def test_code_runner_blocks_imports() -> None: assert "не поддерживает импорт" in result["message"] -def test_code_runner_adds_russian_hint_for_name_error() -> None: - result = run_code("print(missing_name)\n", []) - assert result["correct"] is False - assert "hint_ru" in result - assert "имя" in result["hint_ru"].casefold() - - def test_choice_answer_is_normalized() -> None: question = {"kind": "choice", "answer": "Bool", "explanation": "ok"} assert evaluate(question, " bool ")["correct"] is True From 6efd0accebeec88453e6ea357e00058bef292978 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:28:16 +0300 Subject: [PATCH 3/3] fix: SOL review - restore content.py, fix test split, guard code-question lookup, fraction threshold --- app/content.py | 41 +++++++++++++++++++++++++---------------- app/main.py | 13 +++++++------ tests/test_api.py | 34 +++++++++++++++++----------------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/app/content.py b/app/content.py index c3d27ca..256338a 100644 --- a/app/content.py +++ b/app/content.py @@ -285,17 +285,20 @@ def lesson( ), text( "if-result", - "Что выведет этот код?\n\nif 3 > 5:\n print('да')\nelse:\n print('нет')", + "Что выведет `if 3 > 5: print('да') else: print('нет')`?", ["нет"], "3 не больше 5, поэтому выполняется ветка else.", ), code( "if-code", - "Дано `temperature = 22`. Если температура не меньше 25, выведи «Надень шорты», иначе — «Возьми куртку».", - "temperature = 22\n# напиши условие и выведи подходящую одежду\n", - [{"kind": "stdout", "expected": "Возьми куртку"}], - "22 меньше 25, поэтому выполняется ветка else и выводится «Возьми куртку».", - "Сравни temperature с 25 через >=. В if выведи «Надень шорты», в else — «Возьми куртку».", + "Напиши `is_adult(age)`, возвращающую True при возрасте 18 и больше.", + "def is_adult(age):\n # твой код\n pass\n", + [ + {"kind": "call", "call": "is_adult(18)", "expected": True}, + {"kind": "call", "call": "is_adult(17)", "expected": False}, + ], + "Условие внутри функции помогает вернуть нужный логический результат.", + "Сравни age с 18 и верни результат сравнения либо используй if/else.", ), ], ), @@ -335,11 +338,14 @@ def lesson( text("for-sum", "Чему равна сумма чисел в `range(1, 4)`?", ["6"], "Это 1 + 2 + 3 = 6."), code( "for-code", - "Выведи числа от 1 до 5, каждое с новой строки.", - "for number in range(1, 6):\n # выведи number\n", - [{"kind": "stdout", "expected": "1\n2\n3\n4\n5"}], - "range(1, 6) даёт числа от 1 до 5: верхняя граница не включается.", - "В теле цикла вызови print(number).", + "Напиши `sum_to(number)`, возвращающую сумму от 1 до number включительно.", + "def sum_to(number):\n # твой код\n pass\n", + [ + {"kind": "call", "call": "sum_to(1)", "expected": 1}, + {"kind": "call", "call": "sum_to(5)", "expected": 15}, + ], + "Цикл избавляет от ручного сложения каждого числа.", + "Создай total = 0 и пройди range(1, number + 1).", ), ], ), @@ -385,11 +391,14 @@ def lesson( ), code( "while-code", - "Дано `start = 5`. Выведи обратный отсчёт до 1, каждое число с новой строки, затем выведи «Пуск!».", - "start = 5\nwhile start > 0:\n # выведи start и уменьши его\n# выведи «Пуск!»\n", - [{"kind": "stdout", "expected": "5\n4\n3\n2\n1\nПуск!"}], - "while работает, пока start больше нуля: после каждого вывода уменьши start на 1, а затем выведи «Пуск!».", - "В цикле напиши print(start) и start -= 1. После цикла — print('Пуск!').", + "Напиши `countdown(start)`, возвращающую список от start до 1 через while.", + "def countdown(start):\n # твой код\n pass\n", + [ + {"kind": "call", "call": "countdown(1)", "expected": [1]}, + {"kind": "call", "call": "countdown(4)", "expected": [4, 3, 2, 1]}, + ], + "while хорош, когда счётчик меняется до достижения границы.", + "Начни с result = [] и уменьшай start после добавления в список.", ), ], ), diff --git a/app/main.py b/app/main.py index 77fbff2..c58b8d0 100644 --- a/app/main.py +++ b/app/main.py @@ -192,13 +192,14 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict: question_ids = {question["id"] for question in lesson["questions"]} results, correct_count = grade_answers(submission.answers, question_ids) total = len(question_ids) - code_question_id = next( + code_question_ids = [ question["id"] for question in lesson["questions"] if question["kind"] == "code" + ] + code_correct = bool(code_question_ids) and all( + any(r["question_id"] == question_id and r["correct"] for r in results) + for question_id in code_question_ids ) - code_correct = next( - item["correct"] for item in results if item["question_id"] == code_question_id - ) - passed = code_correct and correct_count >= 2 + passed = (not code_question_ids or code_correct) and correct_count * 3 >= total * 2 gained = 0 if passed: gained, _ = save_lesson(lesson_id, correct_count, total, lesson["xp"]) @@ -213,7 +214,7 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict: if passed else ( "Для зачёта нужно решить задачу на код" - if correct_count >= 2 and not code_correct + if correct_count * 3 >= total * 2 and not code_correct else "Нужно минимум 2 верных ответа из 3. Попробуй ещё раз — это нормально." ), } diff --git a/tests/test_api.py b/tests/test_api.py index 2e42a1b..9f649a9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -40,6 +40,23 @@ def test_lesson_unlocking_and_progression() -> None: def test_extended_course_unlocks_after_foundation() -> None: with TestClient(app) as client: client.post("/api/reset") + for lesson in LESSONS[:12]: + save_lesson(lesson["id"], 3, 3, lesson["xp"]) + + response = client.get("/api/lessons/operators-arithmetic") + assert response.status_code == 200 + assert response.json()["order"] == 13 + + code_check = client.post( + "/api/code/check", + json={ + "question_id": "operators-arithmetic-code", + "answer": "def increment(value):\n return value + 1\n", + }, + ) + assert code_check.status_code == 200 + assert code_check.json()["correct"] is True + client.post("/api/reset") def test_lesson_requires_correct_code_for_completion() -> None: @@ -71,20 +88,3 @@ def test_lesson_requires_correct_code_for_completion() -> None: response = client.post("/api/lessons/hello/submit", json={"answers": code_only}) assert response.json()["passed"] is False client.post("/api/reset") - for lesson in LESSONS[:12]: - save_lesson(lesson["id"], 3, 3, lesson["xp"]) - - response = client.get("/api/lessons/operators-arithmetic") - assert response.status_code == 200 - assert response.json()["order"] == 13 - - code_check = client.post( - "/api/code/check", - json={ - "question_id": "operators-arithmetic-code", - "answer": "def increment(value):\n return value + 1\n", - }, - ) - assert code_check.status_code == 200 - assert code_check.json()["correct"] is True - client.post("/api/reset")