From 3f11305045610038ec6e34b14171ad3ab3f1b6bb Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:52:53 +0000 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20TLE=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8B=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Часть #962 — находка TW-1-01 ## Что было не так `docs/dev/web-contracts.md` описывает `timeout_s` как «лимит, который был превышен», а в карточку уезжала константа: `CONFIG.timeout_seconds`. Врало это не в теории. Веб принимает `timeout_s` в параметрах запроса (`web/runs.py:468`) и честно грейдит с ним — то есть при запросе с 1.5 с решение падало по 1.5 с, а карточка называла человеку 5 с из конфига. Смотрят на это поле ровно за одним: понять, во сколько не уложился. ## Что сделано Фактический лимит вычисляется **один раз** в `grade_path` и уезжает в оба места — в прогон и в карточку. Раньше эти два места считали его независимо, что и позволило им разойтись. Когда лимит не передан (CLI-потребители), настройка читается **в момент вызова** (`get_config()`), а не через связанный на импорте `CONFIG`: иначе `--timeout` и настройки лаунчера сюда снова не дойдут. ## Проверка - 7 тестов: явный лимит попадает в карточку; без него берётся конфиг; конфиг читается в момент вызова; поле остаётся контрактным только у TLE; ноль не подменяется конфигом (`or` здесь был бы дефектом); - `python scripts/preflight.py` — чисто: 5422 passed, 98 skipped. ## Что осталось в #962 Вторая находка (`AUD-3-01`) снялась сама: документ аудита 2026-07-30 переехал в `docs/archive/` и больше не заявляет открытыми закрытые находки. Co-Authored-By: Claude Opus 5 Co-Authored-By: Artem Markitanov <86671904+ArtVsMark@users.noreply.github.com> --- changelog.d/tle-timeout-actual.fixed.md | 1 + src/stepik_grader/web/viewmodels.py | 17 +++++- tests/test_tle_timeout_actual.py | 72 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 changelog.d/tle-timeout-actual.fixed.md create mode 100644 tests/test_tle_timeout_actual.py diff --git a/changelog.d/tle-timeout-actual.fixed.md b/changelog.d/tle-timeout-actual.fixed.md new file mode 100644 index 00000000..7f9e9c06 --- /dev/null +++ b/changelog.d/tle-timeout-actual.fixed.md @@ -0,0 +1 @@ +Карточка TLE называет лимит, который был превышен, а не константу конфига: веб принимает `timeout_s` в запросе и грейдит с ним, поэтому прежнее число врало ровно там, где человек и смотрит — во сколько он не уложился. Фактический лимит считается один раз и уезжает и в прогон, и в карточку; при прогоне по умолчанию настройка читается в момент вызова, так что `--timeout` до неё доходит (#962) diff --git a/src/stepik_grader/web/viewmodels.py b/src/stepik_grader/web/viewmodels.py index 0cfd9271..3cf26f04 100644 --- a/src/stepik_grader/web/viewmodels.py +++ b/src/stepik_grader/web/viewmodels.py @@ -283,6 +283,7 @@ def _case_view( source: str = "", missing_queue_path: pathlib.Path | None = None, lang: str = DEFAULT_LANG, + timeout_s: float | None = None, ) -> dict[str, Any]: """Представление одного тест-кейса для UI — ErrorCard для WA/RE/TLE (issue #125).""" error = case.get("error", "") @@ -351,7 +352,13 @@ def _case_view( view["stderr"] = error view["exit_code"] = case.get("exit_code") if verdict == "TLE": - view["timeout_s"] = CONFIG.timeout_seconds + # issue #962 (TW-1-01): контракт обещает «лимит, который был превышен», а + # здесь стояла константа конфига. Веб принимает `timeout_s` в запросе и + # честно грейдит с ним — то есть карточка TLE называла пользователю + # чужое число ровно там, где оно и нужно. Фактический лимит приходит + # параметром; без него читаем конфиг В МОМЕНТ ВЫЗОВА, а не связанный на + # импорте `CONFIG`, иначе флаги CLI сюда снова не дойдут. + view["timeout_s"] = get_config().timeout_seconds if timeout_s is None else timeout_s if verdict == "RE": view["glossary_ids"] = glossary_ids if not glossary_ids: @@ -813,6 +820,11 @@ def _shown(sol: pathlib.Path) -> str: shown = _rel(display_path, base) return display_path.name if shown == "." else shown + # Фактический лимит прогона считается ОДИН раз: с ним грейдится решение, он + # же уезжает в карточку TLE. Раньше эти два места расходились — прогон шёл с + # запрошенным таймаутом, а карточка называла конфигурационный (issue #962). + effective_timeout = get_config().timeout_seconds if timeout is None else timeout + rows: list[dict[str, Any]] = [] graded: list[tuple[pathlib.Path, SolutionResult]] = [] # issue #395: для истории for sol in solutions: @@ -825,7 +837,7 @@ def _shown(sol: pathlib.Path) -> str: res = run_tests( sol, test_dir, - timeout=CONFIG.timeout_seconds if timeout is None else timeout, + timeout=effective_timeout, max_memory_mb=max_memory_mb, progress_callback=progress_callback, cancel_event=cancel_event, @@ -861,6 +873,7 @@ def _shown(sol: pathlib.Path) -> str: source=_shown(sol), missing_queue_path=missing_queue_path, lang=lang, + timeout_s=effective_timeout, ) for i, c in enumerate(res["cases"], 1) ], diff --git a/tests/test_tle_timeout_actual.py b/tests/test_tle_timeout_actual.py new file mode 100644 index 00000000..711f32fd --- /dev/null +++ b/tests/test_tle_timeout_actual.py @@ -0,0 +1,72 @@ +"""Карточка TLE называет лимит, который был превышен (issue #962, TW-1-01). + +Контракт `web-contracts.md` обещает для `timeout_s` именно превышенный лимит, а +в карточку уезжала константа конфига. Веб при этом принимает `timeout_s` в +запросе и честно грейдит с ним — то есть число врало ровно там, где оно нужно: +человек смотрит на карточку, чтобы понять, во сколько не уложился. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from stepik_grader import config +from stepik_grader.web import viewmodels + + +def _tle_case() -> dict[str, Any]: + return { + "passed": False, + "output": [], + "expected": ["42"], + "diff": "", + "time": 5.0, + "memory": 0.0, + "error": "Timeout after 1.5s", + "timed_out": True, + "verdict": "TLE", + "exit_code": None, + } + + +def test_card_reports_the_limit_that_was_exceeded() -> None: + """Прогон шёл с 1.5 с — карточка обязана назвать 1.5, а не конфиг.""" + view = viewmodels._case_view(1, _tle_case(), timeout_s=1.5) + + assert view["timeout_s"] == 1.5 + + +def test_without_explicit_timeout_config_is_used() -> None: + """Прогон по умолчанию — значение конфига, поведение прежнее.""" + view = viewmodels._case_view(1, _tle_case()) + + assert view["timeout_s"] == config.get_config().timeout_seconds + + +def test_config_is_read_at_call_time() -> None: + """`--timeout` и настройки лаунчера обязаны доходить сюда.""" + config.override_config(timeout_seconds=9.0) + try: + view = viewmodels._case_view(1, _tle_case()) + assert view["timeout_s"] == 9.0 + finally: + config.reset_config_cache() + + +@pytest.mark.parametrize("verdict", ["WA", "RE", "OK"]) +def test_field_appears_only_for_tle(verdict: str) -> None: + """Поле контрактно только у TLE — лишнее там читалось бы как лимит прогона.""" + case = _tle_case() | {"verdict": verdict, "timed_out": verdict == "TLE"} + + view = viewmodels._case_view(1, case, timeout_s=1.5) + + assert "timeout_s" not in view + + +def test_zero_timeout_is_not_swallowed() -> None: + """0 — валидный лимит, а `or` подменил бы его конфигом.""" + view = viewmodels._case_view(1, _tle_case(), timeout_s=0.0) + + assert view["timeout_s"] == 0.0