Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/tle-timeout-actual.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Карточка TLE называет лимит, который был превышен, а не константу конфига: веб принимает `timeout_s` в запросе и грейдит с ним, поэтому прежнее число врало ровно там, где человек и смотрит — во сколько он не уложился. Фактический лимит считается один раз и уезжает и в прогон, и в карточку; при прогоне по умолчанию настройка читается в момент вызова, так что `--timeout` до неё доходит (#962)
17 changes: 15 additions & 2 deletions src/stepik_grader/web/viewmodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)
],
Expand Down
72 changes: 72 additions & 0 deletions tests/test_tle_timeout_actual.py
Original file line number Diff line number Diff line change
@@ -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