From 4b0b1836bd99bfce3131faa6f95716fe825314c8 Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 17:38:21 +0200 Subject: [PATCH 1/6] Run Black on files that are modified in the rest of the PR. --- cms/grading/scoretypes/Sum.py | 20 ++- cms/grading/scoretypes/abc.py | 167 +++++++++++------- cms/server/contest/handlers/__init__.py | 88 ++++----- cms/server/contest/handlers/api.py | 109 +++++++----- cmscontrib/SolutionChecker.py | 8 +- .../grading/scoretypes/GroupMinTest.py | 101 ++++++----- .../unit_tests/grading/scoretypes/SumTest.py | 56 +++--- 7 files changed, 307 insertions(+), 242 deletions(-) diff --git a/cms/grading/scoretypes/Sum.py b/cms/grading/scoretypes/Sum.py index 9df93139fe..3456313802 100644 --- a/cms/grading/scoretypes/Sum.py +++ b/cms/grading/scoretypes/Sum.py @@ -31,6 +31,7 @@ class Sum(ScoreTypeAlone): multiplied by the integer parameter. """ + parameters: int # Mark strings for localization. N_("#") @@ -120,8 +121,7 @@ def compute_score(self, submission_result): # XXX Lexicographical order by codename indices = sorted(self.public_testcases.keys()) - evaluations = dict((ev.codename, ev) - for ev in submission_result.evaluations) + evaluations = dict((ev.codename, ev) for ev in submission_result.evaluations) testcases = [] public_testcases = [] score = 0.0 @@ -131,13 +131,15 @@ def compute_score(self, submission_result): this_score = float(evaluations[idx].outcome) * self.parameters tc_outcome = self.get_public_outcome(this_score) score += this_score - testcases.append({ - "idx": idx, - "outcome": tc_outcome, - "text": evaluations[idx].text, - "time": evaluations[idx].execution_time, - "memory": evaluations[idx].execution_memory, - }) + testcases.append( + { + "idx": idx, + "outcome": tc_outcome, + "text": evaluations[idx].text, + "time": evaluations[idx].execution_time, + "memory": evaluations[idx].execution_memory, + } + ) if self.public_testcases[idx]: public_score += this_score public_testcases.append(testcases[-1]) diff --git a/cms/grading/scoretypes/abc.py b/cms/grading/scoretypes/abc.py index 127067f1d4..dbcd89286a 100644 --- a/cms/grading/scoretypes/abc.py +++ b/cms/grading/scoretypes/abc.py @@ -42,7 +42,6 @@ from cms.server.jinja2_toolbox import GLOBAL_ENVIRONMENT from jinja2 import Template - logger = logging.getLogger(__name__) @@ -59,8 +58,12 @@ class ScoreType(metaclass=ABCMeta): TEMPLATE = "" - def __init__(self, parameters: object, public_testcases: dict[str, bool], - score_precision: int): + def __init__( + self, + parameters: object, + public_testcases: dict[str, bool], + score_precision: int, + ): """Initializer. parameters: format is specified in the subclasses. @@ -75,12 +78,14 @@ def __init__(self, parameters: object, public_testcases: dict[str, bool], # Preload the maximum possible scores. try: - self.max_score, self.max_public_score, self.ranking_headers = \ + self.max_score, self.max_public_score, self.ranking_headers = ( self.max_scores() + ) except Exception as e: raise ValueError( "Unable to instantiate score type (probably due to invalid " - "values for the score type parameters): %s." % e) + "values for the score type parameters): %s." % e + ) self.template: Template = GLOBAL_ENVIRONMENT.from_string(self.TEMPLATE) @@ -110,7 +115,8 @@ def format_score( """ return "%s / %s" % ( translation.format_decimal(score), - translation.format_decimal(max_score)) + translation.format_decimal(max_score), + ) def get_html_details( self, @@ -132,20 +138,25 @@ def get_html_details( _ = translation.gettext n_ = translation.ngettext if score_details is None: - logger.error("Found a null score details string. " - "Try invalidating scores.") + logger.error( + "Found a null score details string. " "Try invalidating scores." + ) return _("Score details temporarily unavailable.") else: # FIXME we should provide to the template all the variables # of a typical CWS context as it's entitled to expect them. try: - return self.template.render(details=score_details, - feedback_level=feedback_level, - translation=translation, - gettext=_, ngettext=n_) + return self.template.render( + details=score_details, + feedback_level=feedback_level, + translation=translation, + gettext=_, + ngettext=n_, + ) except Exception: - logger.exception("Found an invalid score details string. " - "Try invalidating scores.") + logger.exception( + "Found an invalid score details string. " "Try invalidating scores." + ) return _("Score details temporarily unavailable.") @abstractmethod @@ -189,6 +200,7 @@ class ScoreTypeAlone(ScoreType): obtain the score of a single submission and max_scores. """ + pass @@ -202,7 +214,9 @@ class ScoreTypeGroupParametersDict(TypedDict): # the format of parameters is impossible to type-hint correctly, it seems... # this hint is (mostly) correct for the methods this base class implements, # subclasses might need a longer tuple. -ScoreTypeGroupParameters = tuple[float, int | str | list[str]] | ScoreTypeGroupParametersDict +ScoreTypeGroupParameters = ( + tuple[float, int | str | list[str]] | ScoreTypeGroupParametersDict +) class ScoreTypeGroup(ScoreTypeAlone): @@ -223,6 +237,7 @@ class ScoreTypeGroup(ScoreTypeAlone): 'reduce'. """ + parameters: list[ScoreTypeGroupParameters] # Mark strings for localization. @@ -350,22 +365,22 @@ def get_max_score(self, group_parameter: ScoreTypeGroupParameters) -> float: score = group_parameter[0] else: score = group_parameter["max_score"] - assert ( - round( - score, - self.score_precision - ) == score - ), (f"The max score for a subtask" - "has more precision than the task allows.") + assert round(score, self.score_precision) == score, ( + f"The max score for a subtask" "has more precision than the task allows." + ) return score - def get_testcases(self, group_parameter: ScoreTypeGroupParameters) -> int | str | list[str]: + def get_testcases( + self, group_parameter: ScoreTypeGroupParameters + ) -> int | str | list[str]: if isinstance(group_parameter, tuple) or isinstance(group_parameter, list): return group_parameter[1] else: return group_parameter["testcases"] - def get_always_show_testcases(self, group_parameter: ScoreTypeGroupParameters) -> bool: + def get_always_show_testcases( + self, group_parameter: ScoreTypeGroupParameters + ) -> bool: if isinstance(group_parameter, tuple) or isinstance(group_parameter, list): return False else: @@ -411,18 +426,20 @@ def retrieve_target_testcases(self) -> list[list[str]]: regexp = re.compile(t) target = [tc for tc in indices if regexp.match(tc)] if not target: - raise ValueError( - "No testcase matches against the regexp '%s'" % t) + raise ValueError("No testcase matches against the regexp '%s'" % t) targets.append(target) return targets - elif all(isinstance(t, list) for t in t_params) and all(all(isinstance(t, str) for t in s) for s in t_params): + elif all(isinstance(t, list) for t in t_params) and all( + all(isinstance(t, str) for t in s) for s in t_params + ): return t_params raise ValueError( "In the score type parameters, the second value of each element " - "must have the same type (int, unicode or list of strings)") + "must have the same type (int, unicode or list of strings)" + ) def max_scores(self): """See ScoreType.max_score.""" @@ -472,76 +489,90 @@ def compute_score(self, submission_result): tc_first_lowest_score = None for tc_idx in target: tc_score = float(evaluations[tc_idx].outcome) - tc_outcome = self.get_public_outcome( - tc_score, parameter) + tc_outcome = self.get_public_outcome(tc_score, parameter) time_limit_was_exceeded = False - if evaluations[tc_idx].text == [EVALUATION_MESSAGES.get("timeout").message]: + if evaluations[tc_idx].text == [ + EVALUATION_MESSAGES.get("timeout").message + ]: time_limit_was_exceeded = True - testcases.append({ - "idx": tc_idx, - "outcome": tc_outcome, - "text": evaluations[tc_idx].text, - "time": evaluations[tc_idx].execution_time, - "time_limit": evaluations[tc_idx].dataset.time_limit, - "time_limit_was_exceeded": time_limit_was_exceeded, - "memory": evaluations[tc_idx].execution_memory, - "show_in_restricted_feedback": self.public_testcases[tc_idx], - "show_in_oi_restricted_feedback": self.public_testcases[tc_idx]}) + testcases.append( + { + "idx": tc_idx, + "outcome": tc_outcome, + "text": evaluations[tc_idx].text, + "time": evaluations[tc_idx].execution_time, + "time_limit": evaluations[tc_idx].dataset.time_limit, + "time_limit_was_exceeded": time_limit_was_exceeded, + "memory": evaluations[tc_idx].execution_memory, + "show_in_restricted_feedback": self.public_testcases[tc_idx], + "show_in_oi_restricted_feedback": self.public_testcases[tc_idx], + } + ) if self.public_testcases[tc_idx]: public_testcases.append(testcases[-1]) - if tc_first_lowest_score is None or \ - tc_score < tc_first_lowest_score: + if ( + tc_first_lowest_score is None + or tc_score < tc_first_lowest_score + ): tc_first_lowest_idx = tc_idx tc_first_lowest_score = tc_score else: public_testcases.append({"idx": tc_idx}) st_score_fraction = self.reduce( - [float(evaluations[tc_idx].outcome) for tc_idx in target], - parameter) + [float(evaluations[tc_idx].outcome) for tc_idx in target], parameter + ) st_score = st_score_fraction * self.get_max_score(parameter) rounded_score = round(st_score, score_precision) - if tc_first_lowest_idx is not None and st_score_fraction < 1.0 and not self.get_always_show_testcases(parameter): + if ( + tc_first_lowest_idx is not None + and st_score_fraction < 1.0 + and not self.get_always_show_testcases(parameter) + ): for tc in testcases: if not self.public_testcases[tc["idx"]]: continue - tc["show_in_restricted_feedback"] = ( - tc["idx"] <= tc_first_lowest_idx) + tc["show_in_restricted_feedback"] = tc["idx"] <= tc_first_lowest_idx tc["show_in_oi_restricted_feedback"] = ( - tc["idx"] == tc_first_lowest_idx) + tc["idx"] == tc_first_lowest_idx + ) score += rounded_score - subtasks.append({ - "idx": st_idx, - # We store the fraction so that an "example" testcase - # with a max score of zero is still properly rendered as - # correct or incorrect. - "score_fraction": st_score_fraction, - # But we also want the properly rounded score for display. - "score": rounded_score, - "max_score": self.get_max_score(parameter), - "testcases": testcases}) + subtasks.append( + { + "idx": st_idx, + # We store the fraction so that an "example" testcase + # with a max score of zero is still properly rendered as + # correct or incorrect. + "score_fraction": st_score_fraction, + # But we also want the properly rounded score for display. + "score": rounded_score, + "max_score": self.get_max_score(parameter), + "testcases": testcases, + } + ) if all(self.public_testcases[tc_idx] for tc_idx in target): public_score += rounded_score public_subtasks.append(subtasks[-1]) else: - public_subtasks.append({"idx": st_idx, - "testcases": public_testcases}) + public_subtasks.append({"idx": st_idx, "testcases": public_testcases}) ranking_details.append("%g" % rounded_score) - - # The following line should be unnecessary since subtask scores - # are rounded. However we are using floats not Decimals - # and this can cause errors. So we round again to be sure. + + # The following line should be unnecessary since subtask scores + # are rounded. However we are using floats not Decimals + # and this can cause errors. So we round again to be sure. score = round(score, score_precision) return score, subtasks, public_score, public_subtasks, ranking_details @abstractmethod - def get_public_outcome(self, outcome: float, parameter: ScoreTypeGroupParameters) -> str: + def get_public_outcome( + self, outcome: float, parameter: ScoreTypeGroupParameters + ) -> str: """Return a public outcome from an outcome. The public outcome is shown to the user, and this method @@ -558,7 +589,9 @@ def get_public_outcome(self, outcome: float, parameter: ScoreTypeGroupParameters pass @abstractmethod - def reduce(self, outcomes: list[float], parameter: ScoreTypeGroupParameters) -> float: + def reduce( + self, outcomes: list[float], parameter: ScoreTypeGroupParameters + ) -> float: """Return the score of a subtask given the outcomes. outcomes: the outcomes of the submission in diff --git a/cms/server/contest/handlers/__init__.py b/cms/server/contest/handlers/__init__.py index fef7f6c1a6..a529b7b7e2 100644 --- a/cms/server/contest/handlers/__init__.py +++ b/cms/server/contest/handlers/__init__.py @@ -23,89 +23,77 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from .taskusertest import \ - UserTestInterfaceHandler, \ - UserTestHandler, \ - UserTestStatusHandler, \ - UserTestDetailsHandler, \ - UserTestIOHandler, \ - UserTestFileHandler -from .tasksubmission import \ - SubmitHandler, \ - TaskSubmissionsHandler, \ - SubmissionStatusHandler, \ - SubmissionDetailsHandler, \ - SubmissionFileHandler, \ - UseTokenHandler -from .task import \ - TaskDescriptionHandler, \ - TaskStatementViewHandler, \ - TaskAttachmentViewHandler -from .main import \ - LoginHandler, \ - LogoutHandler, \ - RegistrationHandler, \ - StartHandler, \ - NotificationsHandler, \ - DocumentationHandler -from .communication import \ - CommunicationHandler, \ - QuestionHandler -from .api import \ - ApiLoginHandler, \ - ApiSubmissionListHandler, \ - ApiSubmitHandler, \ - ApiTaskListHandler - +from .taskusertest import ( + UserTestInterfaceHandler, + UserTestHandler, + UserTestStatusHandler, + UserTestDetailsHandler, + UserTestIOHandler, + UserTestFileHandler, +) +from .tasksubmission import ( + SubmitHandler, + TaskSubmissionsHandler, + SubmissionStatusHandler, + SubmissionDetailsHandler, + SubmissionFileHandler, + UseTokenHandler, +) +from .task import ( + TaskDescriptionHandler, + TaskStatementViewHandler, + TaskAttachmentViewHandler, +) +from .main import ( + LoginHandler, + LogoutHandler, + RegistrationHandler, + StartHandler, + NotificationsHandler, + DocumentationHandler, +) +from .communication import CommunicationHandler, QuestionHandler +from .api import ( + ApiLoginHandler, + ApiSubmissionListHandler, + ApiSubmitHandler, + ApiTaskListHandler, +) HANDLERS = [ - # Main - (r"/login", LoginHandler), (r"/logout", LogoutHandler), (r"/register", RegistrationHandler), (r"/start", StartHandler), (r"/notifications", NotificationsHandler), (r"/documentation", DocumentationHandler), - # Tasks - (r"/tasks/(.*)/description", TaskDescriptionHandler), (r"/tasks/(.*)/statements/([^/]*)(?:/.*)?", TaskStatementViewHandler), (r"/tasks/(.*)/attachments/(.*)", TaskAttachmentViewHandler), - # Task submissions - (r"/tasks/(.*)/submit", SubmitHandler), (r"/tasks/(.*)/submissions", TaskSubmissionsHandler), (r"/tasks/(.*)/submissions/([1-9][0-9]*)", SubmissionStatusHandler), - (r"/tasks/(.*)/submissions/([1-9][0-9]*)/details", - SubmissionDetailsHandler), - (r"/tasks/(.*)/submissions/([1-9][0-9]*)/files/(.*)", - SubmissionFileHandler), + (r"/tasks/(.*)/submissions/([1-9][0-9]*)/details", SubmissionDetailsHandler), + (r"/tasks/(.*)/submissions/([1-9][0-9]*)/files/(.*)", SubmissionFileHandler), (r"/tasks/(.*)/submissions/([1-9][0-9]*)/token", UseTokenHandler), - # Task usertests - (r"/testing", UserTestInterfaceHandler), (r"/tasks/(.*)/test", UserTestHandler), (r"/tasks/(.*)/tests/([1-9][0-9]*)", UserTestStatusHandler), (r"/tasks/(.*)/tests/([1-9][0-9]*)/details", UserTestDetailsHandler), (r"/tasks/(.*)/tests/([1-9][0-9]*)/(input|output)", UserTestIOHandler), (r"/tasks/(.*)/tests/([1-9][0-9]*)/files/(.*)", UserTestFileHandler), - # Communications - (r"/communication", CommunicationHandler), (r"/question", QuestionHandler), - # API (r"/api/login", ApiLoginHandler), (r"/api/task_list", ApiTaskListHandler), (r"/api/(.*)/submit", ApiSubmitHandler), (r"/api/(.*)/submission_list", ApiSubmissionListHandler), - # The following prefixes are handled by WSGI middlewares: # * /static, defined in cms/io/web_service.py # * /docs, defined in cms/server/contest/server.py diff --git a/cms/server/contest/handlers/api.py b/cms/server/contest/handlers/api.py index 9cba1231ce..c833d7c68f 100644 --- a/cms/server/contest/handlers/api.py +++ b/cms/server/contest/handlers/api.py @@ -16,9 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""API handlers for CMS. - -""" +"""API handlers for CMS.""" import ipaddress import logging @@ -26,8 +24,7 @@ from cms.db.submission import Submission from cms.server import multi_contest from cms.server.contest.authentication import validate_login -from cms.server.contest.submission import \ - UnacceptableSubmission, accept_submission +from cms.server.contest.submission import UnacceptableSubmission, accept_submission from .contest import ContestHandler, api_login_required from ..phase_management import actual_phase_required @@ -35,9 +32,7 @@ class ApiContestHandler(ContestHandler): - """An extension of ContestHandler marking the request as a part of the API. - - """ + """An extension of ContestHandler marking the request as a part of the API.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -45,9 +40,8 @@ def __init__(self, *args, **kwargs): class ApiLoginHandler(ApiContestHandler): - """Login handler. + """Login handler.""" - """ @multi_contest def post(self): current_user = self.get_current_user() @@ -59,32 +53,54 @@ def post(self): if current_user is not None: if username != "" and current_user.user.username != username: self.json( - {"error": f"Logged in as {current_user.user.username} but trying to login as {username}"}, 400) + { + "error": f"Logged in as {current_user.user.username} but trying to login as {username}" + }, + 400, + ) else: cookie_name = self.contest.name + "_login" cookie = self.get_secure_cookie(cookie_name) - self.json({"login_data": self.request.headers.get( - "X-CMS-Authorization", cookie if cookie is not None else "Already-Logged-In")}) + self.json( + { + "login_data": self.request.headers.get( + "X-CMS-Authorization", + cookie if cookie is not None else "Already-Logged-In", + ) + } + ) return try: ip_address = ipaddress.ip_address(self.request.remote_ip) except ValueError: - logger.warning("Invalid IP address provided by Tornado: %s", - self.request.remote_ip) + logger.warning( + "Invalid IP address provided by Tornado: %s", self.request.remote_ip + ) return None participation, login_data = validate_login( - self.sql_session, self.contest, self.timestamp, username, password, - ip_address, admin_token=admin_token) + self.sql_session, + self.contest, + self.timestamp, + username, + password, + ip_address, + admin_token=admin_token, + ) if participation is None: self.json({"error": "Login failed"}, 403) elif login_data is not None: cookie_name = self.contest.name + "_login" - self.json({"login_data": self.create_signed_value( - cookie_name, login_data).decode()}) + self.json( + { + "login_data": self.create_signed_value( + cookie_name, login_data + ).decode() + } + ) else: self.json({}) @@ -93,9 +109,8 @@ def check_xsrf_cookie(self): class ApiTaskListHandler(ApiContestHandler): - """Handler to list all tasks and their statements. + """Handler to list all tasks and their statements.""" - """ @api_login_required @actual_phase_required(0, 3) @multi_contest @@ -106,16 +121,19 @@ def get(self): name = task.name statements = [s for s in task.statements] sub_format = task.submission_format - tasks.append({"name": name, - "statements": statements, - "submission_format": sub_format}) + tasks.append( + { + "name": name, + "statements": statements, + "submission_format": sub_format, + } + ) self.json({"tasks": tasks}) class ApiSubmitHandler(ApiContestHandler): - """Handles the received submissions. + """Handles the received submissions.""" - """ @api_login_required @actual_phase_required(0, 3) @multi_contest @@ -133,9 +151,13 @@ def post(self, task_name: str): # of a contestant, allow overriding. if self.impersonated_by_admin: try: - official = self.get_boolean_argument('override_official', official) - override_max_number = self.get_boolean_argument('override_max_number', False) - override_min_interval = self.get_boolean_argument('override_min_interval', False) + official = self.get_boolean_argument("override_official", official) + override_max_number = self.get_boolean_argument( + "override_max_number", False + ) + override_min_interval = self.get_boolean_argument( + "override_min_interval", False + ) except ValueError as err: self.json({"error": str(err)}, 400) return @@ -145,29 +167,32 @@ def post(self, task_name: str): try: submission = accept_submission( - self.sql_session, self.service.file_cacher, self.current_user, - task, self.timestamp, self.request.files, - self.get_argument("language", None), official, + self.sql_session, + self.service.file_cacher, + self.current_user, + task, + self.timestamp, + self.request.files, + self.get_argument("language", None), + official, override_max_number=override_max_number, override_min_interval=override_min_interval, ) self.sql_session.commit() except UnacceptableSubmission as e: - logger.info("API submission rejected: `%s' - `%s'", - e.subject, e.formatted_text) + logger.info( + "API submission rejected: `%s' - `%s'", e.subject, e.formatted_text + ) self.json({"error": e.subject, "details": e.formatted_text}, 422) else: - logger.info( - f'API submission accepted: Submission ID {submission.id}') - self.service.evaluation_service.new_submission( - submission_id=submission.id) - self.json({'id': str(submission.opaque_id)}) + logger.info(f"API submission accepted: Submission ID {submission.id}") + self.service.evaluation_service.new_submission(submission_id=submission.id) + self.json({"id": str(submission.opaque_id)}) class ApiSubmissionListHandler(ApiContestHandler): - """Retrieves the list of submissions on a task. + """Retrieves the list of submissions on a task.""" - """ @api_login_required @actual_phase_required(0, 3) @multi_contest @@ -182,4 +207,4 @@ def get(self, task_name: str): .filter(Submission.task == task) .all() ) - self.json({'list': [{"id": str(s.opaque_id)} for s in submissions]}) + self.json({"list": [{"id": str(s.opaque_id)} for s in submissions]}) diff --git a/cmscontrib/SolutionChecker.py b/cmscontrib/SolutionChecker.py index e5cc37dc88..eeb87c9004 100755 --- a/cmscontrib/SolutionChecker.py +++ b/cmscontrib/SolutionChecker.py @@ -52,13 +52,12 @@ class RedAlertFormatter(logging.Formatter): YELLOW_FORMAT = YELLOW + "%(levelname)8s" + RSET + " %(message)s" - FORMATS = { logging.DEBUG: BASE_FORMAT, logging.INFO: BASE_FORMAT, logging.WARNING: YELLOW_FORMAT, logging.ERROR: RED_FORMAT, - logging.CRITICAL: RED_FORMAT + logging.CRITICAL: RED_FORMAT, } def format(self, record): @@ -66,6 +65,7 @@ def format(self, record): formatter = logging.Formatter(log_fmt) return formatter.format(record) + class SolutionChecker: def __init__( self, base_url: str, username: Optional[str], password: Optional[str] = None @@ -116,7 +116,9 @@ def submit(self, task_name: str, file_path: str) -> str: response.raise_for_status() return response.json().get("id") - def poll_status(self, task_name: str, filename: str, submission_id: str) -> Dict[str, Any]: + def poll_status( + self, task_name: str, filename: str, submission_id: str + ) -> Dict[str, Any]: status_url = f"{self.base_url}/tasks/{task_name}/submissions/{submission_id}" while True: response = self.session.get(status_url, headers=self.auth_header) diff --git a/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py b/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py index 294471c750..a7eecdf37b 100755 --- a/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py +++ b/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py @@ -21,8 +21,9 @@ import unittest from cms.grading.scoretypes.GroupMin import GroupMin -from cmstestsuite.unit_tests.grading.scoretypes.scoretypetestutils \ - import ScoreTypeTestMixin +from cmstestsuite.unit_tests.grading.scoretypes.scoretypetestutils import ( + ScoreTypeTestMixin, +) class TestGroupMin(ScoreTypeTestMixin, unittest.TestCase): @@ -88,49 +89,69 @@ def test_max_scores_regexp(self): """Test max score is correct when groups are regexp-defined.""" s1, s2, s3 = 10.5, 30.5, 59 parameters = [[0, "0_*"], [s1, "1_*"], [s2, "2_*"], [s3, "3_*"]] - header = ["Subtask 0 (0)", - "Subtask 1 (10.5)", "Subtask 2 (30.5)", "Subtask 3 (59)"] + header = [ + "Subtask 0 (0)", + "Subtask 1 (10.5)", + "Subtask 2 (30.5)", + "Subtask 3 (59)", + ] # Only group 1_* is public. public_testcases = dict(self._public_testcases) - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, s1, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, s1, header), + ) # All groups are public for testcase in public_testcases.keys(): public_testcases[testcase] = True - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, s1 + s2 + s3, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, s1 + s2 + s3, header), + ) # No groups are public for testcase in public_testcases.keys(): public_testcases[testcase] = False - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, 0, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, 0, header), + ) def test_max_scores_number(self): """Test max score is correct when groups are number-defined.""" s1, s2, s3 = 10.5, 30.5, 59 parameters = [[0, 1], [s1, 2], [s2, 2], [s3, 2]] - header = ["Subtask 0 (0)", - "Subtask 1 (10.5)", "Subtask 2 (30.5)", "Subtask 3 (59)"] + header = [ + "Subtask 0 (0)", + "Subtask 1 (10.5)", + "Subtask 2 (30.5)", + "Subtask 3 (59)", + ] # Only group 1_* is public. public_testcases = dict(self._public_testcases) - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, s1, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, s1, header), + ) # All groups are public for testcase in public_testcases.keys(): public_testcases[testcase] = True - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, s1 + s2 + s3, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, s1 + s2 + s3, header), + ) # No groups are public for testcase in public_testcases.keys(): public_testcases[testcase] = False - self.assertEqual(GroupMin(parameters, public_testcases, 2).max_scores(), - (s1 + s2 + s3, 0.0, header)) + self.assertEqual( + GroupMin(parameters, public_testcases, 2).max_scores(), + (s1 + s2 + s3, 0.0, header), + ) def test_compute_score(self): s1, s2, s3 = 10.5, 30.5, 59 @@ -141,23 +162,21 @@ def test_compute_score(self): # All correct. self.assertComputeScore( gmin.compute_score(sr), - s1 + s2 + s3, s1, [0, s1, s2, s3], [ - {"idx": 0}, - {"idx": 1}, - {"idx": 2}, - {"idx": 3} - ]) + s1 + s2 + s3, + s1, + [0, s1, s2, s3], + [{"idx": 0}, {"idx": 1}, {"idx": 2}, {"idx": 3}], + ) # Some non-public subtask is incorrect. self.set_outcome(sr, "3_1", 0.0) self.assertComputeScore( gmin.compute_score(sr), - s1 + s2, s1, [0, s1, s2, 0], [ - {"idx": 0}, - {"idx": 1}, - {"idx": 2}, - {"idx": 3} - ]) + s1 + s2, + s1, + [0, s1, s2, 0], + [{"idx": 0}, {"idx": 1}, {"idx": 2}, {"idx": 3}], + ) # Also the public subtask is incorrect. self.set_outcome(sr, "1_0", 0.0) @@ -165,24 +184,22 @@ def test_compute_score(self): sr.evaluations[1].outcome = 0.0 self.assertComputeScore( gmin.compute_score(sr), - s2, 0.0, [0, 0, s2, 0], [ - {"idx": 0}, - {"idx": 1}, - {"idx": 2}, - {"idx": 3} - ]) + s2, + 0.0, + [0, 0, s2, 0], + [{"idx": 0}, {"idx": 1}, {"idx": 2}, {"idx": 3}], + ) # Some partial results. self.set_outcome(sr, "3_0", 0.5) self.set_outcome(sr, "3_1", 0.1) self.assertComputeScore( gmin.compute_score(sr), - s2 + s3 * 0.1, 0.0, [0, 0, s2, s3 * 0.1], [ - {"idx": 0}, - {"idx": 1}, - {"idx": 2}, - {"idx": 3} - ]) + s2 + s3 * 0.1, + 0.0, + [0, 0, s2, s3 * 0.1], + [{"idx": 0}, {"idx": 1}, {"idx": 2}, {"idx": 3}], + ) if __name__ == "__main__": diff --git a/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py b/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py index 1a243026cf..b2efa37363 100755 --- a/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py +++ b/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py @@ -21,8 +21,9 @@ import unittest from cms.grading.scoretypes.Sum import Sum -from cmstestsuite.unit_tests.grading.scoretypes.scoretypetestutils \ - import ScoreTypeTestMixin +from cmstestsuite.unit_tests.grading.scoretypes.scoretypetestutils import ( + ScoreTypeTestMixin, +) class TestSum(ScoreTypeTestMixin, unittest.TestCase): @@ -50,9 +51,10 @@ def test_paramaters_invalid(self): def test_max_scores(self): testcase_score = 10.5 - self.assertEqual(Sum(testcase_score, - self._public_testcases, 2).max_scores(), - (testcase_score * 4, testcase_score, [])) + self.assertEqual( + Sum(testcase_score, self._public_testcases, 2).max_scores(), + (testcase_score * 4, testcase_score, []), + ) def test_compute_score(self): testcase_score = 10.5 @@ -62,45 +64,41 @@ def test_compute_score(self): # All correct. self.assertComputeScore( st.compute_score(sr), - testcase_score * 4, testcase_score, [], [ - {"idx": '0'}, - {"idx": '1'}, - {"idx": '2'}, - {"idx": '3'} - ]) + testcase_score * 4, + testcase_score, + [], + [{"idx": "0"}, {"idx": "1"}, {"idx": "2"}, {"idx": "3"}], + ) # Some non-public subtask is incorrect. self.set_outcome(sr, "3", 0.0) self.assertComputeScore( st.compute_score(sr), - testcase_score * 3, testcase_score, [], [ - {"idx": '0'}, - {"idx": '1'}, - {"idx": '2'}, - {"idx": '3'} - ]) + testcase_score * 3, + testcase_score, + [], + [{"idx": "0"}, {"idx": "1"}, {"idx": "2"}, {"idx": "3"}], + ) # Also the public subtask is incorrect. self.set_outcome(sr, "1", 0.0) self.assertComputeScore( st.compute_score(sr), - testcase_score * 2, 0.0, [], [ - {"idx": '0'}, - {"idx": '1'}, - {"idx": '2'}, - {"idx": '3'} - ]) + testcase_score * 2, + 0.0, + [], + [{"idx": "0"}, {"idx": "1"}, {"idx": "2"}, {"idx": "3"}], + ) # Now the public subtask has some partial scores. self.set_outcome(sr, "1", 0.2) self.assertComputeScore( st.compute_score(sr), - testcase_score * 2.2, testcase_score * 0.2, [], [ - {"idx": '0'}, - {"idx": '1'}, - {"idx": '2'}, - {"idx": '3'} - ]) + testcase_score * 2.2, + testcase_score * 0.2, + [], + [{"idx": "0"}, {"idx": "1"}, {"idx": "2"}, {"idx": "3"}], + ) if __name__ == "__main__": From 41453a7b8d0af58963d425375cde7ee48413a02a Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 17:42:54 +0200 Subject: [PATCH 2/6] Fix split string literals in scoretypes. --- cms/grading/scoretypes/abc.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cms/grading/scoretypes/abc.py b/cms/grading/scoretypes/abc.py index dbcd89286a..ad12be40a7 100644 --- a/cms/grading/scoretypes/abc.py +++ b/cms/grading/scoretypes/abc.py @@ -138,9 +138,7 @@ def get_html_details( _ = translation.gettext n_ = translation.ngettext if score_details is None: - logger.error( - "Found a null score details string. " "Try invalidating scores." - ) + logger.error("Found a null score details string. Try invalidating scores.") return _("Score details temporarily unavailable.") else: # FIXME we should provide to the template all the variables @@ -155,7 +153,7 @@ def get_html_details( ) except Exception: logger.exception( - "Found an invalid score details string. " "Try invalidating scores." + "Found an invalid score details string. Try invalidating scores." ) return _("Score details temporarily unavailable.") @@ -365,9 +363,9 @@ def get_max_score(self, group_parameter: ScoreTypeGroupParameters) -> float: score = group_parameter[0] else: score = group_parameter["max_score"] - assert round(score, self.score_precision) == score, ( - f"The max score for a subtask" "has more precision than the task allows." - ) + assert ( + round(score, self.score_precision) == score + ), "The max score for a subtask has more precision than the task allows." return score def get_testcases( From bf47d0710b82ff893fa96c050e47237334dceeff Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 16:42:00 +0200 Subject: [PATCH 3/6] Extract feedback-level filtering into get_json_details. Also run black on affected files. --- cms/grading/scoretypes/Sum.py | 47 +++++++- cms/grading/scoretypes/abc.py | 103 +++++++++++++++--- .../grading/scoretypes/GroupMinTest.py | 38 +++++++ .../unit_tests/grading/scoretypes/SumTest.py | 32 ++++++ 4 files changed, 202 insertions(+), 18 deletions(-) diff --git a/cms/grading/scoretypes/Sum.py b/cms/grading/scoretypes/Sum.py index 3456313802..1a7a6fbb13 100644 --- a/cms/grading/scoretypes/Sum.py +++ b/cms/grading/scoretypes/Sum.py @@ -18,6 +18,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from cms import FEEDBACK_LEVEL_FULL, FEEDBACK_LEVEL_RESTRICTED from . import ScoreTypeAlone @@ -41,6 +42,8 @@ class Sum(ScoreTypeAlone): N_("Memory used") N_("N/A") TEMPLATE = """\ +{% set show_timing = (details|any("contains", "time") + or details|any("contains", "memory")) %} @@ -53,7 +56,7 @@ class Sum(ScoreTypeAlone): - {% if feedback_level == FEEDBACK_LEVEL_FULL %} + {% if show_timing %} @@ -76,16 +79,16 @@ class Sum(ScoreTypeAlone): - {% if feedback_level == FEEDBACK_LEVEL_FULL %} + {% if show_timing %} + {% if show_timing %} @@ -103,6 +110,38 @@ class Sum(ScoreTypeAlone):
{% trans %}Details{% endtrans %} {% trans %}Execution time{% endtrans %} {{ loop.index }} {{ _(tc["outcome"]) }} {{ tc["text"]|format_status_text }} - {% if tc["time"] is not none %} + {% if "time" in tc and tc["time"] is not none %} {{ tc["time"]|format_duration }} {% else %} {% trans %}N/A{% endtrans %} {% endif %} - {% if tc["memory"] is not none %} + {% if "memory" in tc and tc["memory"] is not none %} {{ tc["memory"]|format_size }} {% else %} {% trans %}N/A{% endtrans %} @@ -94,7 +97,11 @@ class Sum(ScoreTypeAlone): {% endif %} {% else %}
+ {% else %} + + {% endif %} {% trans %}N/A{% endtrans %}
""" + def get_json_details( + self, + score_details: object, + feedback_level: str = FEEDBACK_LEVEL_RESTRICTED, + ) -> object: + """Filter score_details for Sum score type according to the + feedback level. + + """ + if score_details is None: + return None + if not isinstance(score_details, list): + return score_details + + filtered_testcases = [] + for tc in score_details: + if "outcome" in tc and "text" in tc: + filtered_tc = { + "idx": tc["idx"], + "outcome": tc["outcome"], + "text": tc["text"], + } + if feedback_level == FEEDBACK_LEVEL_FULL: + if "time" in tc: + filtered_tc["time"] = tc["time"] + if "memory" in tc: + filtered_tc["memory"] = tc["memory"] + filtered_testcases.append(filtered_tc) + else: + filtered_testcases.append({"idx": tc.get("idx")}) + return filtered_testcases + def max_scores(self): """See ScoreType.max_score.""" public_score = 0.0 diff --git a/cms/grading/scoretypes/abc.py b/cms/grading/scoretypes/abc.py index ad12be40a7..ad02038316 100644 --- a/cms/grading/scoretypes/abc.py +++ b/cms/grading/scoretypes/abc.py @@ -35,7 +35,11 @@ from typing import TypedDict, NotRequired from abc import ABCMeta, abstractmethod -from cms import FEEDBACK_LEVEL_RESTRICTED +from cms import ( + FEEDBACK_LEVEL_FULL, + FEEDBACK_LEVEL_RESTRICTED, + FEEDBACK_LEVEL_OI_RESTRICTED, +) from cms.db import SubmissionResult from cms.grading.steps import EVALUATION_MESSAGES from cms.locale import Translation, DEFAULT_TRANSLATION @@ -118,6 +122,23 @@ def format_score( translation.format_decimal(max_score), ) + def get_json_details( + self, + score_details: object, + feedback_level: str = FEEDBACK_LEVEL_RESTRICTED, + ) -> object: + """Return a JSON-serializable object representing the score details + of a submission filtered according to the feedback level. + + score_details: the data saved by the score type + itself in the database; can be public or private. + feedback_level: the level of details to show to users. + + return: JSON-serializable filtered score details. + + """ + return score_details + def get_html_details( self, score_details: object, @@ -144,8 +165,9 @@ def get_html_details( # FIXME we should provide to the template all the variables # of a typical CWS context as it's entitled to expect them. try: + filtered_details = self.get_json_details(score_details, feedback_level) return self.template.render( - details=score_details, + details=filtered_details, feedback_level=feedback_level, translation=translation, gettext=_, @@ -248,6 +270,8 @@ class ScoreTypeGroup(ScoreTypeAlone): N_("N/A") TEMPLATE = """\ {% for st in details %} +{% set show_timing = (st["testcases"]|any("contains", "time") + or st["testcases"]|any("contains", "memory")) %} {% if "score_fraction" in st %} {% if st["score_fraction"] >= 1.0 %}
@@ -287,7 +311,7 @@ class ScoreTypeGroup(ScoreTypeAlone): {% trans %}Details{% endtrans %} - {% if feedback_level == FEEDBACK_LEVEL_FULL %} + {% if show_timing %} {% trans %}Execution time{% endtrans %} @@ -299,13 +323,7 @@ class ScoreTypeGroup(ScoreTypeAlone): {% for tc in st["testcases"] %} - {% set show_tc = "outcome" in tc - and ((feedback_level == FEEDBACK_LEVEL_FULL) - or (feedback_level == FEEDBACK_LEVEL_RESTRICTED - and tc["show_in_restricted_feedback"]) - or (feedback_level == FEEDBACK_LEVEL_OI_RESTRICTED - and tc["show_in_oi_restricted_feedback"])) %} - {% if show_tc %} + {% if "outcome" in tc %} {% if tc["outcome"] == "Correct" %} {% elif tc["outcome"] == "Not correct" %} @@ -318,7 +336,7 @@ class ScoreTypeGroup(ScoreTypeAlone): {{ tc["text"]|format_status_text }} - {% if feedback_level == FEEDBACK_LEVEL_FULL %} + {% if show_timing %} {% if "time_limit_was_exceeded" in tc and tc["time_limit_was_exceeded"] %} > {{ tc["time_limit"]|format_duration }} @@ -338,10 +356,9 @@ class ScoreTypeGroup(ScoreTypeAlone): {% endif %} {% else %} - {% if feedback_level != FEEDBACK_LEVEL_OI_RESTRICTED %} {{ loop.index }} - {% if feedback_level == FEEDBACK_LEVEL_FULL %} + {% if show_timing %} {% else %} @@ -349,7 +366,6 @@ class ScoreTypeGroup(ScoreTypeAlone): {% trans %}N/A{% endtrans %} - {% endif %} {% endif %} {% endfor %} @@ -358,6 +374,65 @@ class ScoreTypeGroup(ScoreTypeAlone):
{% endfor %}""" + def get_json_details( + self, + score_details: object, + feedback_level: str = FEEDBACK_LEVEL_RESTRICTED, + ) -> object: + """Filter score_details for subtask-based score types according to + the feedback level. + + """ + if not isinstance(score_details, list): + return score_details + + filtered_subtasks = [] + for st in score_details: + filtered_st = { + "idx": st["idx"], + "score_fraction": st["score_fraction"], + "score": st["score"], + "max_score": st["max_score"], + } + + filtered_testcases = [] + for tc in st.get("testcases", []): + if feedback_level == FEEDBACK_LEVEL_FULL: + show_tc = True + elif feedback_level == FEEDBACK_LEVEL_RESTRICTED: + show_tc = tc.get("show_in_restricted_feedback", False) + elif feedback_level == FEEDBACK_LEVEL_OI_RESTRICTED: + show_tc = tc.get("show_in_oi_restricted_feedback", False) + else: + raise ValueError(f"Invalid feedback level {feedback_level}") + + if show_tc and "outcome" in tc: + filtered_tc = { + "idx": tc["idx"], + "outcome": tc["outcome"], + "text": tc.get("text"), + } + if feedback_level == FEEDBACK_LEVEL_FULL: + if "time" in tc: + filtered_tc["time"] = tc["time"] + if "time_limit" in tc: + filtered_tc["time_limit"] = tc["time_limit"] + if "time_limit_was_exceeded" in tc: + filtered_tc["time_limit_was_exceeded"] = tc[ + "time_limit_was_exceeded" + ] + if "memory" in tc: + filtered_tc["memory"] = tc["memory"] + filtered_testcases.append(filtered_tc) + else: + if feedback_level != FEEDBACK_LEVEL_OI_RESTRICTED: + filtered_testcases.append({"idx": tc.get("idx")}) + + filtered_st["testcases"] = filtered_testcases + filtered_subtasks.append(filtered_st) + + return filtered_subtasks + def get_max_score(self, group_parameter: ScoreTypeGroupParameters) -> float: if isinstance(group_parameter, tuple) or isinstance(group_parameter, list): score = group_parameter[0] diff --git a/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py b/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py index a7eecdf37b..082a7d02e5 100755 --- a/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py +++ b/cmstestsuite/unit_tests/grading/scoretypes/GroupMinTest.py @@ -201,6 +201,44 @@ def test_compute_score(self): [{"idx": 0}, {"idx": 1}, {"idx": 2}, {"idx": 3}], ) + def test_get_json_details(self): + from cms import ( + FEEDBACK_LEVEL_FULL, + FEEDBACK_LEVEL_RESTRICTED, + FEEDBACK_LEVEL_OI_RESTRICTED, + ) + + parameters = [[10.0, "1_*"], [20.0, "2_*"]] + gmin = GroupMin(parameters, self._public_testcases, 2) + sr = self.get_submission_result(self._public_testcases) + self.set_outcome(sr, "1_0", 0.0) + self.set_outcome(sr, "1_1", 0.0) + _, subtasks, _, _, _ = gmin.compute_score(sr) + + # FULL feedback level includes all testcases and times/memory + full = gmin.get_json_details(subtasks, FEEDBACK_LEVEL_FULL) + self.assertEqual(len(full), 2) + self.assertEqual(len(full[0]["testcases"]), 2) + self.assertIn("time", full[0]["testcases"][0]) + self.assertIn("memory", full[0]["testcases"][0]) + self.assertIn("outcome", full[0]["testcases"][0]) + + # RESTRICTED feedback level strips time and keeps placeholders for + # hidden testcases + restricted = gmin.get_json_details(subtasks, FEEDBACK_LEVEL_RESTRICTED) + self.assertEqual(len(restricted), 2) + self.assertNotIn("time", restricted[0]["testcases"][0]) + self.assertNotIn("memory", restricted[0]["testcases"][0]) + self.assertEqual(restricted[0]["testcases"][0]["outcome"], "Not correct") + # 1_1 was hidden because 1_0 was lowest, but placeholder exists + self.assertNotIn("outcome", restricted[0]["testcases"][1]) + self.assertEqual(restricted[0]["testcases"][1]["idx"], "1_1") + + # OI_RESTRICTED feedback level omits non-visible testcases + oi = gmin.get_json_details(subtasks, FEEDBACK_LEVEL_OI_RESTRICTED) + self.assertEqual(len(oi[0]["testcases"]), 1) + self.assertEqual(oi[0]["testcases"][0]["idx"], "1_0") + if __name__ == "__main__": unittest.main() diff --git a/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py b/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py index b2efa37363..31907dc867 100755 --- a/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py +++ b/cmstestsuite/unit_tests/grading/scoretypes/SumTest.py @@ -100,6 +100,38 @@ def test_compute_score(self): [{"idx": "0"}, {"idx": "1"}, {"idx": "2"}, {"idx": "3"}], ) + def test_get_json_details(self): + from cms import FEEDBACK_LEVEL_FULL, FEEDBACK_LEVEL_RESTRICTED + + st = Sum(10, self._public_testcases, 2) + sr = self.get_submission_result(self._public_testcases) + _, testcases, _, _, _ = st.compute_score(sr) + + full = st.get_json_details(testcases, FEEDBACK_LEVEL_FULL) + self.assertEqual(len(full), 4) + self.assertIn("time", full[0]) + self.assertIn("memory", full[0]) + + restricted = st.get_json_details(testcases, FEEDBACK_LEVEL_RESTRICTED) + self.assertEqual(len(restricted), 4) + self.assertNotIn("time", restricted[0]) + self.assertNotIn("memory", restricted[0]) + + def test_get_html_details(self): + from cms import FEEDBACK_LEVEL_FULL, FEEDBACK_LEVEL_RESTRICTED + + st = Sum(10, self._public_testcases, 2) + sr = self.get_submission_result(self._public_testcases) + _, testcases, _, _, _ = st.compute_score(sr) + + html_full = st.get_html_details(testcases, FEEDBACK_LEVEL_FULL) + self.assertIn("execution-time", html_full) + self.assertIn("memory-used", html_full) + + html_restricted = st.get_html_details(testcases, FEEDBACK_LEVEL_RESTRICTED) + self.assertNotIn("execution-time", html_restricted) + self.assertNotIn("memory-used", html_restricted) + if __name__ == "__main__": unittest.main() From 11ce14bcb065fe692f55a19e0fbd5d1c3d216c13 Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 16:42:07 +0200 Subject: [PATCH 4/6] Expose submission details as JSON. Includes an admin-only full_details endpoint. --- cms/server/contest/handlers/__init__.py | 10 + cms/server/contest/handlers/api.py | 76 ++++++++ .../unit_tests/server/contest/api_test.py | 180 ++++++++++++++++++ docs/API.rst | 41 ++++ 4 files changed, 307 insertions(+) create mode 100644 cmstestsuite/unit_tests/server/contest/api_test.py diff --git a/cms/server/contest/handlers/__init__.py b/cms/server/contest/handlers/__init__.py index a529b7b7e2..94321cbbf0 100644 --- a/cms/server/contest/handlers/__init__.py +++ b/cms/server/contest/handlers/__init__.py @@ -55,6 +55,8 @@ from .communication import CommunicationHandler, QuestionHandler from .api import ( ApiLoginHandler, + ApiSubmissionDetailsHandler, + ApiSubmissionFullDetailsHandler, ApiSubmissionListHandler, ApiSubmitHandler, ApiTaskListHandler, @@ -94,6 +96,14 @@ (r"/api/task_list", ApiTaskListHandler), (r"/api/(.*)/submit", ApiSubmitHandler), (r"/api/(.*)/submission_list", ApiSubmissionListHandler), + ( + r"/api/(.*)/submissions/([1-9][0-9]*)/details", + ApiSubmissionDetailsHandler, + ), + ( + r"/api/(.*)/submissions/([1-9][0-9]*)/full_details", + ApiSubmissionFullDetailsHandler, + ), # The following prefixes are handled by WSGI middlewares: # * /static, defined in cms/io/web_service.py # * /docs, defined in cms/server/contest/server.py diff --git a/cms/server/contest/handlers/api.py b/cms/server/contest/handlers/api.py index c833d7c68f..92059c86db 100644 --- a/cms/server/contest/handlers/api.py +++ b/cms/server/contest/handlers/api.py @@ -21,6 +21,7 @@ import ipaddress import logging +from cms import FEEDBACK_LEVEL_FULL from cms.db.submission import Submission from cms.server import multi_contest from cms.server.contest.authentication import validate_login @@ -208,3 +209,78 @@ def get(self, task_name: str): .all() ) self.json({"list": [{"id": str(s.opaque_id)} for s in submissions]}) + + +class ApiSubmissionDetailsHandler(ApiContestHandler): + """Retrieves the feedback-level-restricted details of a submission + on a task. + + """ + + @api_login_required + @actual_phase_required(0, 1, 2, 3, 4) + @multi_contest + def get(self, task_name: str, opaque_id: str): + task = self.get_task(task_name) + if task is None: + self.json({"error": "Task not found"}, 404) + return + + submission = self.get_submission(task, opaque_id) + if submission is None: + self.json({"error": "Submission not found"}, 404) + return + + sr = submission.get_result(task.active_dataset) + score_type = task.active_dataset.score_type_object + + details = None + if sr is not None and sr.scored(): + is_analysis_mode = self.r_params["actual_phase"] == 3 + if submission.tokened() or is_analysis_mode: + raw_details = sr.score_details + else: + raw_details = sr.public_score_details + + if is_analysis_mode: + feedback_level = FEEDBACK_LEVEL_FULL + else: + feedback_level = task.feedback_level + + details = score_type.get_json_details(raw_details, feedback_level) + + self.json({"details": details}) + + +class ApiSubmissionFullDetailsHandler(ApiContestHandler): + """Retrieves the unfiltered details of a submission on a task + (admin only). + + """ + + @api_login_required + @actual_phase_required(0, 1, 2, 3, 4) + @multi_contest + def get(self, task_name: str, opaque_id: str): + if not self.impersonated_by_admin: + self.json({"error": "Admin impersonation required"}, 403) + return + + task = self.get_task(task_name) + if task is None: + self.json({"error": "Task not found"}, 404) + return + + submission = self.get_submission(task, opaque_id) + if submission is None: + self.json({"error": "Submission not found"}, 404) + return + + sr = submission.get_result(task.active_dataset) + score_type = task.active_dataset.score_type_object + + details = None + if sr is not None and sr.scored(): + details = score_type.get_json_details(sr.score_details, FEEDBACK_LEVEL_FULL) + + self.json({"details": details}) diff --git a/cmstestsuite/unit_tests/server/contest/api_test.py b/cmstestsuite/unit_tests/server/contest/api_test.py new file mode 100644 index 0000000000..f3759ef403 --- /dev/null +++ b/cmstestsuite/unit_tests/server/contest/api_test.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +# Contest Management System - http://cms-dev.github.io/ +# Copyright © 2026 Luca Versari +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Unit tests for submission details API handlers.""" + +import unittest +from unittest.mock import MagicMock + +from cms import FEEDBACK_LEVEL_RESTRICTED, FEEDBACK_LEVEL_FULL +from cms.server.contest.handlers.api import ( + ApiSubmissionDetailsHandler, + ApiSubmissionFullDetailsHandler, +) + + +class BaseApiHandlerTest(unittest.TestCase): + def setUp(self): + super().setUp() + self.task = MagicMock() + self.task.name = "my_task" + self.task.feedback_level = FEEDBACK_LEVEL_RESTRICTED + + self.score_type = MagicMock() + self.dataset = MagicMock() + self.dataset.score_type_object = self.score_type + self.task.active_dataset = self.dataset + + self.submission = MagicMock() + self.submission.opaque_id = 1 + self.submission_result = MagicMock() + self.submission_result.scored.return_value = True + self.submission_result.score_details = [{"idx": 0, "score": 100.0}] + self.submission_result.public_score_details = [{"idx": 0, "score": 0.0}] + self.submission.get_result.return_value = self.submission_result + self.submission.tokened.return_value = False + + def create_handler( + self, handler_cls, impersonated=False, phase=0, current_user=True + ): + handler = handler_cls.__new__(handler_cls) + handler.impersonated_by_admin = impersonated + handler.contest = MagicMock() + handler.contest.name = "test_contest" + handler._current_user = MagicMock() if current_user else None + handler.request = MagicMock() + handler.request.arguments = {} + handler.request.headers = {} + handler.application = MagicMock() + handler.application.service = MagicMock() + handler.application.service.contest_id = 1 + handler.is_multi_contest = lambda: False + handler.r_params = {"actual_phase": phase} + handler.json_data = None + handler.status_code = 200 + + def fake_json(data, status_code=200): + handler.json_data = data + handler.status_code = status_code + + handler.json = fake_json + + def fake_get_task(name): + return self.task if name == self.task.name else None + + def fake_get_submission(task, opaque_id): + if task == self.task and str(opaque_id) == "1": + return self.submission + return None + + handler.get_task = fake_get_task + handler.get_submission = fake_get_submission + return handler + + +class TestApiSubmissionDetailsHandler(BaseApiHandlerTest): + + def test_task_not_found(self): + handler = self.create_handler(ApiSubmissionDetailsHandler) + handler.get("unknown_task", "1") + self.assertEqual(handler.status_code, 404) + self.assertEqual(handler.json_data, {"error": "Task not found"}) + + def test_submission_not_found(self): + handler = self.create_handler(ApiSubmissionDetailsHandler) + handler.get("my_task", "999") + self.assertEqual(handler.status_code, 404) + self.assertEqual(handler.json_data, {"error": "Submission not found"}) + + def test_contestant_restricted_details(self): + self.score_type.get_json_details.return_value = [{"idx": 0, "filtered": True}] + handler = self.create_handler(ApiSubmissionDetailsHandler, phase=0) + handler.get("my_task", "1") + + self.assertEqual(handler.status_code, 200) + self.assertEqual(handler.json_data, {"details": [{"idx": 0, "filtered": True}]}) + self.score_type.get_json_details.assert_called_once_with( + self.submission_result.public_score_details, + FEEDBACK_LEVEL_RESTRICTED, + ) + + def test_contestant_tokened_details(self): + self.submission.tokened.return_value = True + self.score_type.get_json_details.return_value = [{"idx": 0, "tokened": True}] + handler = self.create_handler(ApiSubmissionDetailsHandler, phase=0) + handler.get("my_task", "1") + + self.assertEqual(handler.status_code, 200) + self.assertEqual(handler.json_data, {"details": [{"idx": 0, "tokened": True}]}) + self.score_type.get_json_details.assert_called_once_with( + self.submission_result.score_details, FEEDBACK_LEVEL_RESTRICTED + ) + + def test_analysis_mode_details(self): + self.submission.tokened.return_value = False + self.score_type.get_json_details.return_value = [{"idx": 0, "analysis": True}] + handler = self.create_handler(ApiSubmissionDetailsHandler, phase=3) + handler.get("my_task", "1") + + self.assertEqual(handler.status_code, 200) + self.assertEqual(handler.json_data, {"details": [{"idx": 0, "analysis": True}]}) + self.score_type.get_json_details.assert_called_once_with( + self.submission_result.score_details, FEEDBACK_LEVEL_FULL + ) + + +class TestApiSubmissionFullDetailsHandler(BaseApiHandlerTest): + + def test_not_impersonated_forbidden(self): + handler = self.create_handler( + ApiSubmissionFullDetailsHandler, impersonated=False + ) + handler.get("my_task", "1") + self.assertEqual(handler.status_code, 403) + self.assertEqual(handler.json_data, {"error": "Admin impersonation required"}) + + def test_impersonated_task_not_found(self): + handler = self.create_handler( + ApiSubmissionFullDetailsHandler, impersonated=True + ) + handler.get("unknown_task", "1") + self.assertEqual(handler.status_code, 404) + + def test_impersonated_submission_not_found(self): + handler = self.create_handler( + ApiSubmissionFullDetailsHandler, impersonated=True + ) + handler.get("my_task", "999") + self.assertEqual(handler.status_code, 404) + + def test_impersonated_returns_full_details(self): + self.score_type.get_json_details.return_value = [{"idx": 0, "full": True}] + handler = self.create_handler( + ApiSubmissionFullDetailsHandler, impersonated=True + ) + handler.get("my_task", "1") + + self.assertEqual(handler.status_code, 200) + self.assertEqual(handler.json_data, {"details": [{"idx": 0, "full": True}]}) + self.score_type.get_json_details.assert_called_once_with( + self.submission_result.score_details, FEEDBACK_LEVEL_FULL + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/API.rst b/docs/API.rst index 8e85be0645..61b9e3fdf8 100644 --- a/docs/API.rst +++ b/docs/API.rst @@ -102,6 +102,47 @@ Additional details on the submission's results can be retrieved by making an authenticated ``GET`` request to ``/tasks/{taskname}/submissions/{id}/details``. The endpoint will return an HTML snippet matching what is seen by contestants. +Submission details (JSON) +========================= + +An authenticated ``GET`` request to +``/api/{taskname}/submissions/{id}/details`` will return a JSON object with the +submission details filtered according to the task's feedback level: + +.. sourcecode:: json + + { + "details": [ + { + "idx": 0, + "score": 10.0, + "max_score": 10.0, + "score_fraction": 1.0, + "testcases": [ + { + "idx": "0_0", + "outcome": "Correct", + "text": ["Output is correct"] + } + ] + } + ] + } + +If the task uses a non-subtask score type such as ``Sum``, ``details`` will +contain a list of testcase objects directly. + +Unfiltered submission details (JSON) +==================================== + +An authenticated ``GET`` request to +``/api/{taskname}/submissions/{id}/full_details`` will return a JSON object with +the complete, unfiltered submission details (including all testcase execution +times and memory usage). + +This endpoint requires an impersonated authentication token (see `Impersonation +of users`_ below) and returns ``403 Forbidden`` for regular contestants. + Impersonation of users ====================== From f611c3ad81ea1aeab35607d13b6d8a04cbb50469 Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 16:42:39 +0200 Subject: [PATCH 5/6] Subtask-level assertions and admin token in SolutionChecker. --- cmscontrib/SolutionChecker.py | 412 ++++++++++++--- .../cmscontrib/SolutionCheckerTest.py | 488 ++++++++++++++++++ 2 files changed, 827 insertions(+), 73 deletions(-) create mode 100644 cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py diff --git a/cmscontrib/SolutionChecker.py b/cmscontrib/SolutionChecker.py index eeb87c9004..0d6bb3852c 100755 --- a/cmscontrib/SolutionChecker.py +++ b/cmscontrib/SolutionChecker.py @@ -23,6 +23,16 @@ - path: path to the solution file. - min_score: minimum expected score. - max_score: maximum expected score. +- checks: optional list of expected subtask outcomes, where each + element can be: + - null (no assertion on this subtask) + - "Accepted" (full score on this subtask) + - "Zero" (0 score on this subtask) + - "PartialScore" (score > 0 and < max_score on this subtask) + - "WrongAnswer" (at least one testcase produced a wrong answer) + - "TimeLimitExceeded" (at least one testcase exceeded CPU time limit) + - "WallTimeLimitExceeded" (at least one testcase exceeded wall time limit) + - "RuntimeError" (at least one testcase failed due to runtime error) Such a file can be generated with `task-maker-rust export-solution-checks`. """ @@ -34,7 +44,7 @@ import re import sys import time -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List import requests @@ -68,27 +78,87 @@ def format(self, record): class SolutionChecker: def __init__( - self, base_url: str, username: Optional[str], password: Optional[str] = None + self, + base_url: str, + username: Optional[str] = None, + password: Optional[str] = None, + admin_token: Optional[str] = None, ): self.base_url = base_url.rstrip("/") self.username = username self.password = password + self.admin_token = admin_token self.session = requests.Session() - self.auth_header = {} + self.auth_header: Dict[str, str] = {} + + @staticmethod + def _extract_error_message(err: requests.exceptions.HTTPError) -> str: + if err.response is not None: + try: + json_data = err.response.json() + if "error" in json_data: + return json_data["error"] + except Exception: + pass + if err.response.text: + return err.response.text.strip() + return str(err) def login(self): - if self.password is None or self.username is None: - logger.info("No password provided, assuming IP autologin.") + if self.admin_token is not None: + if self.username is None: + raise ValueError( + "Admin token requires --username to specify user to impersonate." + ) + login_url = f"{self.base_url}/api/login" + data = {"admin_token": self.admin_token, "username": self.username} + if self.password is not None: + data["password"] = self.password + try: + response = self.session.post(login_url, data=data) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + err_msg = self._extract_error_message(e) + raise RuntimeError( + f"Login with admin token failed: {err_msg}" + ) from None + + res_data = response.json() + self.auth_header = {"X-CMS-Authorization": res_data["login_data"]} + logger.info("Successfully logged in with admin token as %s.", self.username) + return + + if self.username is None and self.password is None: + logger.info("No credentials provided, assuming IP autologin.") return + if self.username is None and self.password is not None: + raise ValueError( + "Password provided without username. Please specify " "--username." + ) + + if self.username is not None and self.password is None: + raise ValueError( + f"Username '{self.username}' provided without password. " + f"Please specify --password or use --admin-token." + ) + login_url = f"{self.base_url}/api/login" - response = self.session.post( - login_url, data={"username": self.username, "password": self.password} - ) - response.raise_for_status() + try: + response = self.session.post( + login_url, + data={"username": self.username, "password": self.password}, + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + err_msg = self._extract_error_message(e) + raise RuntimeError( + f"Login failed for user '{self.username}': {err_msg}" + ) from None + data = response.json() self.auth_header = {"X-CMS-Authorization": data["login_data"]} - logger.info("Successfully logged in.") + logger.info("Successfully logged in as %s.", self.username) def submit(self, task_name: str, file_path: str) -> str: task_list_url = f"{self.base_url}/api/task_list" @@ -137,40 +207,192 @@ def get_time_limit(self, task_name: str) -> float: assert match, "Could not find time limit in task description" return float(match.group(1)) - def has_slow_testcases( - self, task_name: str, submission_id: str, time_limit: float - ) -> bool: - url = f"{self.base_url}/tasks/{task_name}/submissions/{submission_id}/details" + def get_submission_details( + self, task_name: str, submission_id: str + ) -> Optional[List[Dict[str, Any]]]: + endpoint = "full_details" if self.admin_token is not None else "details" + url = ( + f"{self.base_url}/api/{task_name}/submissions/" + f"{submission_id}/{endpoint}" + ) response = self.session.get(url, headers=self.auth_header) response.raise_for_status() + return response.json().get("details") - html = response.text - # Split by subtask. This is quite hacky and relies on subtask - # delimiters having at least two classes (to avoid mixing it - # up with subtask-head/subtask-body). - subtasks = html.split('
\s*\(\s*([\d.]+)\s*/', st) - if score_match and float(score_match.group(1).replace(",", ".")) > 0: - # Find all execution times in this subtask - times = re.findall( - r'\s*(?:>\s*)?([\d.]+)\s*s', st + def has_slow_testcases( + self, details: Optional[List[Dict[str, Any]]], time_limit: float + ) -> bool: + if not details: + return False + + has_times = False + for item in details: + testcases = item.get("testcases") + if testcases is not None: + # Subtask structure + score = item.get("score", 0.0) + score_fraction = item.get("score_fraction", 0.0) + check_slow = (score is not None and score > 0) or ( + score_fraction is not None and score_fraction > 0 ) - if not times: - logger.warning( - "No testcase times found. Ensure feedback levels are configured correctly" - ) - for t in times: - if float(t.replace(",", ".")) > time_limit * 0.5: + for tc in testcases: + t = tc.get("time") + if t is not None: + has_times = True + if check_slow and float(t) > time_limit * 0.5: + return True + else: + # Flat testcase structure (e.g. Sum) + t = item.get("time") + if t is not None: + has_times = True + if float(t) > time_limit * 0.5: return True + + if not has_times and not self.admin_token: + logger.warning( + "No testcase times found. Ensure feedback levels are " + "configured correctly or use --admin-token" + ) return False + @staticmethod + def get_testcase_status(tc: Dict[str, Any]) -> str: + outcome = tc.get("outcome") + text_list = tc.get("text", []) + text_str = ( + text_list[0] + if isinstance(text_list, list) and text_list + else str(text_list) + ) + if "wall clock" in text_str.lower(): + return "WallTimeLimitExceeded" + if tc.get("time_limit_was_exceeded", False) or "timed out" in text_str.lower(): + return "TimeLimitExceeded" + if ( + "signal" in text_str.lower() + or "return code" in text_str.lower() + or "memory limit" in text_str.lower() + ): + return "RuntimeError" + if outcome == "Correct" or "output is correct" in text_str.lower(): + return "Accepted" + if outcome == "Partially correct": + return "PartialScore" + if ( + outcome == "Not correct" + or "output isn't correct" in text_str.lower() + or "wrong answer" in text_str.lower() + ): + return "WrongAnswer" + return "Unknown" + + def check_single_subtask( + self, st: Optional[Dict[str, Any]], st_idx: int, check: Optional[str] + ) -> Optional[str]: + if check is None: + return None + if st is None: + return f"subtask {st_idx}: not found in submission details" + + score_fraction = st.get("score_fraction") + score = st.get("score") + max_score = st.get("max_score") + testcases = st.get("testcases", []) + statuses = { + self.get_testcase_status(tc) + for tc in testcases + if "outcome" in tc or "text" in tc + } + statuses_list = sorted(statuses) + + if check == "Accepted": + if ( + (statuses and not statuses <= {"Accepted"}) + or (score_fraction is not None and score_fraction < 1.0 - 1e-7) + or ( + score is not None + and max_score is not None + and score < max_score - 1e-7 + ) + ): + return ( + f"subtask {st_idx}: expected Accepted, got statuses " + f"{statuses_list}" + ) + + elif check == "Zero": + if (score_fraction is not None and score_fraction > 1e-7) or ( + score is not None and score > 1e-7 + ): + return ( + f"subtask {st_idx}: expected Zero, got score " + f"{score}/{max_score} and statuses {statuses_list}" + ) + + elif check == "PartialScore": + is_partial = False + if score_fraction is not None and 1e-7 < score_fraction < 1.0 - 1e-7: + is_partial = True + elif ( + score is not None + and max_score is not None + and 1e-7 < score < max_score - 1e-7 + ): + is_partial = True + elif "PartialScore" in statuses or ( + "Accepted" in statuses and len(statuses - {"Accepted"}) > 0 + ): + is_partial = True + + if not is_partial: + return ( + f"subtask {st_idx}: expected PartialScore, got score " + f"{score}/{max_score} and statuses {statuses_list}" + ) + + elif check in [ + "WrongAnswer", + "TimeLimitExceeded", + "WallTimeLimitExceeded", + "RuntimeError", + ]: + if check not in statuses: + return ( + f"subtask {st_idx}: expected {check}, got statuses " + f"{statuses_list}" + ) + + else: + return f"subtask {st_idx}: unknown check type '{check}'" + + return None + + def check_subtasks( + self, details: Optional[List[Dict[str, Any]]], checks: list + ) -> List[str]: + if details is None: + return ["Submission details unavailable for subtask checks."] + + errors = [] + subtasks_by_idx = {st.get("idx", i): st for i, st in enumerate(details)} + + for st_idx, check in enumerate(checks): + st = subtasks_by_idx.get(st_idx) + err = self.check_single_subtask(st, st_idx, check) + if err is not None: + errors.append(err) + + return errors + def main(): parser = argparse.ArgumentParser(description="CMS Solution Checker") parser.add_argument( - "--checks-json", "-c", required=True, help="Path to solution_checks.json" + "--checks-json", + "-c", + required=True, + help="Path to solution_checks.json", ) parser.add_argument( "--url", @@ -181,6 +403,9 @@ def main(): parser.add_argument("--task", "-t", required=True, help="Task name") parser.add_argument("--username", "-U", help="CMS username") parser.add_argument("--password", "-p", help="CMS password") + parser.add_argument( + "--admin-token", "-a", help="CMS contest admin token for full details" + ) parser.add_argument( "--quiet", "-q", action="store_true", help="Disable non-warnings" ) @@ -199,48 +424,89 @@ def main(): with open(args.checks_json, "r") as f: checks = json.load(f) - checker = SolutionChecker(args.url, args.username, args.password) - checker.login() - - time_limit = checker.get_time_limit(args.task) - - submissions = {} - logger.info("Submitting %d solutions...", len(checks)) - for criteria in checks: - sol_path = criteria.get("path") - sub_id = checker.submit(args.task, sol_path) - submissions[sol_path] = (sub_id, criteria) - logger.info("Submitted %s: %s", sol_path.split("/")[-1], sub_id) - - has_failures = False - logger.info("Waiting for evaluations...") - for sol_path, (sub_id, criteria) in submissions.items(): - sol_name = sol_path.split("/")[-1] - status = checker.poll_status(args.task, sol_name, sub_id) - failed = False - if status: - score = status["public_score"] - min_score = criteria["min_score"] - max_score = criteria["max_score"] - if score < min_score - 1e-7 or score > max_score + 1e-7: + try: + checker = SolutionChecker( + args.url, + args.username, + args.password, + admin_token=args.admin_token, + ) + checker.login() + + time_limit = checker.get_time_limit(args.task) + + submissions = {} + logger.info("Submitting %d solutions...", len(checks)) + for criteria in checks: + sol_path = criteria.get("path") + sub_id = checker.submit(args.task, sol_path) + submissions[sol_path] = (sub_id, criteria) + logger.info("Submitted %s: %s", sol_path.split("/")[-1], sub_id) + + has_failures = False + logger.info("Waiting for evaluations...") + for sol_path, (sub_id, criteria) in submissions.items(): + sol_name = sol_path.split("/")[-1] + status = checker.poll_status(args.task, sol_name, sub_id) + failed = False + errors = [] + score = 0.0 + details = None + slow = False + + if status: + if status.get("status") == 2: + failed = True + errors.append("Compilation failed.") + else: + score = status.get("public_score", 0.0) + min_score = criteria.get("min_score", 0.0) + max_score = criteria.get("max_score", 100.0) + if score < min_score - 1e-7 or score > max_score + 1e-7: + failed = True + errors.append( + f"score {score} is not in range " + f"[{min_score}, {max_score}]" + ) + + details = checker.get_submission_details(args.task, sub_id) + + if "checks" in criteria and criteria["checks"]: + subtask_errors = checker.check_subtasks( + details, criteria["checks"] + ) + if subtask_errors: + failed = True + errors.extend(subtask_errors) + + if checker.has_slow_testcases(details, time_limit): + slow = True + else: failed = True - error = f"score {score} is not in range [{min_score}, {max_score}]" - else: - failed = True - error = "Evaluation failed." - - if not failed: - logger.info("%20s: check successful", sol_name) - if checker.has_slow_testcases(args.task, sub_id, time_limit): - logger.warning( - "%20s: some testcases took > 50%% of time limit", - sol_name, - ) - else: - has_failures = True - logger.error("%20s: %s", sol_name, error) + errors.append("Evaluation failed.") - return 1 if has_failures else 0 + if not failed: + logger.info("%20s: check successful", sol_name) + if slow: + logger.warning( + "%20s: some testcases took > 50%% of time limit", + sol_name, + ) + else: + has_failures = True + for err in errors: + logger.error("%20s: %s", sol_name, err) + + return 1 if has_failures else 0 + + except (ValueError, RuntimeError) as e: + logger.error("%s", e) + return 1 + except requests.exceptions.HTTPError as e: + err_msg = SolutionChecker._extract_error_message(e) + status_code = e.response.status_code if e.response is not None else "unknown" + logger.error("API error (%s): %s", status_code, err_msg) + return 1 if __name__ == "__main__": diff --git a/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py b/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py new file mode 100644 index 0000000000..fdd1733735 --- /dev/null +++ b/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 + +# Contest Management System - http://cms-dev.github.io/ +# Copyright © 2026 Luca Versari +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Unit tests for SolutionChecker.""" + +import unittest +from unittest.mock import MagicMock, patch + +import requests + +from cmscontrib.SolutionChecker import SolutionChecker + + +class TestSolutionChecker(unittest.TestCase): + + def setUp(self): + super().setUp() + self.checker = SolutionChecker( + base_url="http://localhost:8888/contest", + username="user", + password="pwd", + ) + + def test_check_subtasks_accepted(self): + # Full score -> pass + details = [ + { + "idx": 0, + "score": 20.0, + "max_score": 20.0, + "score_fraction": 1.0, + "testcases": [{"outcome": "Correct"}], + } + ] + self.assertEqual(self.checker.check_subtasks(details, ["Accepted"]), []) + + # Partial score -> fail + details_partial = [ + { + "idx": 0, + "score": 10.0, + "max_score": 20.0, + "score_fraction": 0.5, + "testcases": [{"outcome": "Partially correct"}], + } + ] + errors = self.checker.check_subtasks(details_partial, ["Accepted"]) + self.assertEqual(len(errors), 1) + self.assertIn("expected Accepted", errors[0]) + + def test_check_subtasks_zero(self): + # 0 score -> pass + details = [ + { + "idx": 0, + "score": 0.0, + "max_score": 20.0, + "score_fraction": 0.0, + "testcases": [{"outcome": "Not correct"}], + } + ] + self.assertEqual(self.checker.check_subtasks(details, ["Zero"]), []) + + # Positive score -> fail + details_pos = [ + { + "idx": 0, + "score": 5.0, + "max_score": 20.0, + "score_fraction": 0.25, + "testcases": [{"outcome": "Partially correct"}], + } + ] + errors = self.checker.check_subtasks(details_pos, ["Zero"]) + self.assertEqual(len(errors), 1) + self.assertIn("expected Zero", errors[0]) + + def test_check_subtasks_partial_score(self): + details = [ + { + "idx": 0, + "score": 10.0, + "max_score": 20.0, + "score_fraction": 0.5, + } + ] + self.assertEqual(self.checker.check_subtasks(details, ["PartialScore"]), []) + + # 0 score -> fail + details_zero = [ + { + "idx": 0, + "score": 0.0, + "max_score": 20.0, + "score_fraction": 0.0, + } + ] + self.assertEqual( + len(self.checker.check_subtasks(details_zero, ["PartialScore"])), 1 + ) + + # Full score -> fail + details_full = [ + { + "idx": 0, + "score": 20.0, + "max_score": 20.0, + "score_fraction": 1.0, + } + ] + self.assertEqual( + len(self.checker.check_subtasks(details_full, ["PartialScore"])), 1 + ) + + def test_check_subtasks_wrong_answer(self): + details = [ + { + "idx": 0, + "score": 0.0, + "max_score": 20.0, + "testcases": [ + {"outcome": "Correct", "text": ["Output is correct"]}, + { + "outcome": "Not correct", + "text": ["Output isn't correct"], + }, + ], + } + ] + self.assertEqual(self.checker.check_subtasks(details, ["WrongAnswer"]), []) + + # All correct -> fail, reports got statuses ['Accepted'] + details_correct = [ + { + "idx": 0, + "score": 20.0, + "max_score": 20.0, + "testcases": [ + {"outcome": "Correct", "text": ["Output is correct"]}, + ], + } + ] + errors = self.checker.check_subtasks(details_correct, ["WrongAnswer"]) + self.assertEqual(len(errors), 1) + self.assertIn("expected WrongAnswer, got statuses ['Accepted']", errors[0]) + + def test_check_subtasks_time_limit_exceeded(self): + details = [ + { + "idx": 0, + "testcases": [ + { + "outcome": "Not correct", + "text": ["Execution timed out"], + "time_limit_was_exceeded": True, + } + ], + } + ] + self.assertEqual( + self.checker.check_subtasks(details, ["TimeLimitExceeded"]), [] + ) + + # No TLE -> fail, reports got statuses ['WrongAnswer'] + details_no_tle = [ + { + "idx": 0, + "testcases": [ + { + "outcome": "Not correct", + "text": ["Output isn't correct"], + } + ], + } + ] + errors = self.checker.check_subtasks(details_no_tle, ["TimeLimitExceeded"]) + self.assertEqual(len(errors), 1) + self.assertIn( + "expected TimeLimitExceeded, got statuses ['WrongAnswer']", + errors[0], + ) + + def test_check_subtasks_wall_time_limit_exceeded(self): + details = [ + { + "idx": 0, + "testcases": [ + { + "outcome": "Not correct", + "text": ["Execution timed out (wall clock limit exceeded)"], + } + ], + } + ] + self.assertEqual( + self.checker.check_subtasks(details, ["WallTimeLimitExceeded"]), [] + ) + + def test_check_subtasks_runtime_error(self): + details_signal = [ + { + "idx": 0, + "testcases": [ + { + "outcome": "Not correct", + "text": ["Execution killed by signal"], + } + ], + } + ] + self.assertEqual( + self.checker.check_subtasks(details_signal, ["RuntimeError"]), [] + ) + + details_returncode = [ + { + "idx": 0, + "testcases": [ + { + "outcome": "Not correct", + "text": [ + "Execution failed because the return code was " "nonzero" + ], + } + ], + } + ] + self.assertEqual( + self.checker.check_subtasks(details_returncode, ["RuntimeError"]), + [], + ) + + def test_check_subtasks_multiple_statuses(self): + # Subtask with multiple different testcase failures + details = [ + { + "idx": 0, + "score": 0.0, + "max_score": 20.0, + "testcases": [ + {"outcome": "Correct", "text": ["Output is correct"]}, + { + "outcome": "Not correct", + "text": ["Execution killed by signal 11"], + }, + { + "outcome": "Not correct", + "text": ["Output isn't correct"], + }, + ], + } + ] + # Expecting RuntimeError -> pass (since RuntimeError is in the set) + self.assertEqual(self.checker.check_subtasks(details, ["RuntimeError"]), []) + # Expecting WrongAnswer -> pass (since WrongAnswer is in the set) + self.assertEqual(self.checker.check_subtasks(details, ["WrongAnswer"]), []) + # Expecting TimeLimitExceeded -> fail, reports actual statuses + errors = self.checker.check_subtasks(details, ["TimeLimitExceeded"]) + self.assertEqual(len(errors), 1) + self.assertIn( + "expected TimeLimitExceeded, got statuses " + "['Accepted', 'RuntimeError', 'WrongAnswer']", + errors[0], + ) + + def test_check_subtasks_null_and_unknown(self): + details = [{"idx": 0, "score": 10.0}] + # null / None check -> pass + self.assertEqual(self.checker.check_subtasks(details, [None]), []) + + # Unknown check -> fail + errors = self.checker.check_subtasks(details, ["InvalidCheckType"]) + self.assertEqual(len(errors), 1) + self.assertIn("unknown check type", errors[0]) + + def test_has_slow_testcases(self): + time_limit = 1.0 + # Testcase took 0.6s (> 0.5 * time_limit) on positive subtask -> True + details_slow = [ + { + "idx": 0, + "score": 10.0, + "testcases": [{"time": 0.6}], + } + ] + self.assertTrue(self.checker.has_slow_testcases(details_slow, time_limit)) + + # Testcase took 0.4s (<= 0.5 * time_limit) -> False + details_fast = [ + { + "idx": 0, + "score": 10.0, + "testcases": [{"time": 0.4}], + } + ] + self.assertFalse(self.checker.has_slow_testcases(details_fast, time_limit)) + + # Testcase took 0.8s on a 0-score subtask -> False + details_zero_score = [ + { + "idx": 0, + "score": 0.0, + "score_fraction": 0.0, + "testcases": [{"time": 0.8}], + } + ] + self.assertFalse( + self.checker.has_slow_testcases(details_zero_score, time_limit) + ) + # Test that user's 0-score / sample subtask with times does not warn + details_sample = [ + { + "idx": 0, + "score_fraction": 1.0, + "score": 0.0, + "max_score": 0.0, + "testcases": [ + { + "idx": "000", + "outcome": "Correct", + "text": ["Output is correct"], + "time": 0.001, + "time_limit": 1.0, + "time_limit_was_exceeded": False, + "memory": 262144, + } + ], + } + ] + self.assertFalse(self.checker.has_slow_testcases(details_sample, time_limit)) + + # Truly missing times -> warns + details_no_times = [ + { + "idx": 0, + "score": 10.0, + "testcases": [{"outcome": "Correct"}], + } + ] + with self.assertLogs("cmscontrib.SolutionChecker", level="WARNING") as cm: + self.assertFalse( + self.checker.has_slow_testcases(details_no_times, time_limit) + ) + self.assertTrue(any("No testcase times found" in msg for msg in cm.output)) + + # Flat testcase structure (e.g. Sum score type) + details_flat = [{"idx": 0, "time": 0.8}] + self.assertTrue(self.checker.has_slow_testcases(details_flat, time_limit)) + + def test_login_validations(self): + # Password without username + c1 = SolutionChecker(base_url="http://localhost:8888", password="pwd") + with self.assertRaises(ValueError) as ctx: + c1.login() + self.assertIn("Password provided without username", str(ctx.exception)) + + # Username without password (no admin token) + c2 = SolutionChecker(base_url="http://localhost:8888", username="user") + with self.assertRaises(ValueError) as ctx: + c2.login() + self.assertIn("provided without password", str(ctx.exception)) + + # Admin token without username + c3 = SolutionChecker(base_url="http://localhost:8888", admin_token="admintoken") + with self.assertRaises(ValueError) as ctx: + c3.login() + self.assertIn("Admin token requires --username", str(ctx.exception)) + + # No credentials -> IP autologin + c4 = SolutionChecker(base_url="http://localhost:8888") + c4.login() + self.assertEqual(c4.auth_header, {}) + + @patch("requests.Session.post") + def test_login_admin_token(self, mock_post): + mock_response = MagicMock() + mock_response.json.return_value = {"login_data": "signed_cookie"} + mock_post.return_value = mock_response + + checker = SolutionChecker( + base_url="http://localhost:8888", + username="marago", + admin_token="admintoken", + ) + checker.login() + self.assertEqual(checker.auth_header, {"X-CMS-Authorization": "signed_cookie"}) + mock_post.assert_called_with( + "http://localhost:8888/api/login", + data={"admin_token": "admintoken", "username": "marago"}, + ) + + @patch("requests.Session.post") + def test_login_failure(self, mock_post): + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.json.return_value = {"error": "Invalid credentials"} + http_error = requests.exceptions.HTTPError(response=mock_response) + mock_response.raise_for_status.side_effect = http_error + mock_post.return_value = mock_response + + checker = SolutionChecker( + base_url="http://localhost:8888", + username="user", + password="wrong_password", + ) + with self.assertRaises(RuntimeError) as ctx: + checker.login() + self.assertIn("Invalid credentials", str(ctx.exception)) + + def test_extract_error_message(self): + # JSON response with error field + r1 = MagicMock() + r1.json.return_value = {"error": "The contest is not open"} + err1 = requests.exceptions.HTTPError(response=r1) + self.assertEqual( + SolutionChecker._extract_error_message(err1), + "The contest is not open", + ) + + # Plain text response + r2 = MagicMock() + r2.json.side_effect = Exception("Not JSON") + r2.text = "Forbidden" + err2 = requests.exceptions.HTTPError(response=r2) + self.assertEqual(SolutionChecker._extract_error_message(err2), "Forbidden") + + @patch("requests.Session.get") + def test_get_submission_details_endpoints(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {"details": [{"idx": 0}]} + mock_get.return_value = mock_response + + # Without admin token -> calls /details + checker_user = SolutionChecker( + base_url="http://localhost:8888/contest", + username="u", + password="p", + ) + res = checker_user.get_submission_details("task1", "123") + self.assertEqual(res, [{"idx": 0}]) + mock_get.assert_called_with( + "http://localhost:8888/contest/api/task1/submissions/123/details", + headers={}, + ) + + # With admin token -> calls /full_details + checker_admin = SolutionChecker( + base_url="http://localhost:8888/contest", + username="u", + admin_token="admintoken", + ) + res_admin = checker_admin.get_submission_details("task1", "123") + self.assertEqual(res_admin, [{"idx": 0}]) + mock_get.assert_called_with( + "http://localhost:8888/contest/api/task1/submissions/" "123/full_details", + headers={}, + ) + + def test_check_single_subtask(self): + st_ac = { + "idx": 1, + "score": 20.0, + "max_score": 20.0, + "score_fraction": 1.0, + "testcases": [{"outcome": "Correct"}], + } + self.assertIsNone(self.checker.check_single_subtask(st_ac, 1, "Accepted")) + self.assertIsNotNone(self.checker.check_single_subtask(st_ac, 1, "WrongAnswer")) + self.assertIsNone(self.checker.check_single_subtask(st_ac, 1, None)) + + +if __name__ == "__main__": + unittest.main() From bc08ea851d21f0e311cf54eb65f6ad86677e3b14 Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Sat, 22 Aug 2026 16:42:48 +0200 Subject: [PATCH 6/6] Display final report table in SolutionChecker. --- cmscontrib/SolutionChecker.py | 175 +++++++++++++++++ .../cmscontrib/SolutionCheckerTest.py | 185 ++++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/cmscontrib/SolutionChecker.py b/cmscontrib/SolutionChecker.py index 0d6bb3852c..1510dcfeb8 100755 --- a/cmscontrib/SolutionChecker.py +++ b/cmscontrib/SolutionChecker.py @@ -385,6 +385,160 @@ def check_subtasks( return errors + @staticmethod + def is_subtask_slow(st: Dict[str, Any], time_limit: float) -> bool: + if not st: + return False + score = st.get("score", 0.0) + score_fraction = st.get("score_fraction", 0.0) + check_slow = (score is not None and score > 0) or ( + score_fraction is not None and score_fraction > 0 + ) + if not check_slow: + return False + for tc in st.get("testcases", []): + t = tc.get("time") + if t is not None and float(t) > time_limit * 0.5: + return True + return False + + STATUS_SHORT_CODES = { + "Accepted": "AC", + "WrongAnswer": "WA", + "TimeLimitExceeded": "TLE", + "WallTimeLimitExceeded": "WTL", + "RuntimeError": "RTE", + "PartialScore": "PS", + "Zero": "0", + None: "-", + } + + def format_report_table( + self, + results: List[Dict[str, Any]], + time_limit: float, + use_color: bool = True, + ) -> str: + if not results: + return "" + + RED = "\x1b[31;1m" if use_color else "" + GREEN = "\x1b[32;1m" if use_color else "" + YELLOW = "\x1b[33;1m" if use_color else "" + RSET = "\x1b[0m" if use_color else "" + + num_subtasks = 0 + for r in results: + crit_checks = r.get("criteria", {}).get("checks") + if crit_checks: + num_subtasks = max(num_subtasks, len(crit_checks)) + details = r.get("details") + if isinstance(details, list): + num_subtasks = max(num_subtasks, len(details)) + + rows = [] + for r in results: + sol_name = r["name"] + criteria = r.get("criteria", {}) + checks = criteria.get("checks", []) + details = r.get("details") + compilation_failed = r.get("compilation_failed", False) + subtasks_by_idx = {} + if isinstance(details, list): + subtasks_by_idx = { + st.get("idx", i): st for i, st in enumerate(details) + } + + row_cells = [(sol_name, "")] + + for i in range(num_subtasks): + expected = checks[i] if i < len(checks) else None + cell_text = self.STATUS_SHORT_CODES.get( + expected, str(expected) if expected is not None else "-" + ) + cell_color = GREEN + + if compilation_failed: + cell_color = RED + else: + st = subtasks_by_idx.get(i) + if expected is not None: + err = self.check_single_subtask(st, i, expected) + if err is not None: + cell_color = RED + elif self.is_subtask_slow(st, time_limit): + cell_color = YELLOW + else: + cell_color = GREEN + else: + if st and self.is_subtask_slow(st, time_limit): + cell_color = YELLOW + else: + cell_color = GREEN + + row_cells.append((cell_text, cell_color)) + + if compilation_failed: + score_text = "CE" + expected_str = "" + total_color = RED + else: + score = r.get("score", 0.0) + min_score = criteria.get("min_score", 0.0) + max_score = criteria.get("max_score", 100.0) + score_unexpected = ( + score < min_score - 1e-7 or score > max_score + 1e-7 + ) + + score_text = f"{score:g}" + if score_unexpected: + if abs(min_score - max_score) < 1e-7: + expected_note = f"expected {min_score:g}" + else: + expected_note = f"expected {min_score:g}-{max_score:g}" + expected_str = f" ({expected_note})" + else: + expected_str = "" + + if r.get("failed", False): + total_color = RED + elif r.get("slow", False): + total_color = YELLOW + else: + total_color = GREEN + + rows.append((row_cells, score_text, expected_str, total_color)) + + num_subtask_cols = num_subtasks + 1 # sol_name + subtasks + col_widths = [0] * num_subtask_cols + score_col_width = 0 + + for row_cells, score_text, _, _ in rows: + for col_idx, (text, _) in enumerate(row_cells): + col_widths[col_idx] = max(col_widths[col_idx], len(text)) + score_col_width = max(score_col_width, len(score_text)) + + lines = [] + for row_cells, score_text, expected_str, total_color in rows: + row_strs = [] + for col_idx, (text, color) in enumerate(row_cells): + w = col_widths[col_idx] + align_fmt = f"{text:<{w}}" if col_idx == 0 else f"{text:>{w}}" + if color: + row_strs.append(f"{color}{align_fmt}{RSET}") + else: + row_strs.append(align_fmt) + + aligned_score = f"{score_text:>{score_col_width}}{expected_str}" + if total_color: + row_strs.append(f"{total_color}{aligned_score}{RSET}") + else: + row_strs.append(aligned_score) + + lines.append(" ".join(row_strs)) + + return "\n".join(lines) + def main(): parser = argparse.ArgumentParser(description="CMS Solution Checker") @@ -443,12 +597,14 @@ def main(): submissions[sol_path] = (sub_id, criteria) logger.info("Submitted %s: %s", sol_path.split("/")[-1], sub_id) + results = [] has_failures = False logger.info("Waiting for evaluations...") for sol_path, (sub_id, criteria) in submissions.items(): sol_name = sol_path.split("/")[-1] status = checker.poll_status(args.task, sol_name, sub_id) failed = False + compilation_failed = False errors = [] score = 0.0 details = None @@ -457,6 +613,7 @@ def main(): if status: if status.get("status") == 2: failed = True + compilation_failed = True errors.append("Compilation failed.") else: score = status.get("public_score", 0.0) @@ -497,6 +654,24 @@ def main(): for err in errors: logger.error("%20s: %s", sol_name, err) + results.append( + { + "name": sol_name, + "criteria": criteria, + "score": score, + "compilation_failed": compilation_failed, + "details": details, + "failed": failed, + "slow": slow, + } + ) + + # Print report table + table_str = checker.format_report_table( + results, time_limit, use_color=sys.stdout.isatty() + ) + print("\n" + table_str) + return 1 if has_failures else 0 except (ValueError, RuntimeError) as e: diff --git a/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py b/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py index fdd1733735..6f0b266012 100644 --- a/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py +++ b/cmstestsuite/unit_tests/cmscontrib/SolutionCheckerTest.py @@ -471,6 +471,30 @@ def test_get_submission_details_endpoints(self, mock_get): headers={}, ) + def test_is_subtask_slow(self): + time_limit = 1.0 + # Positive score and slow testcase (> 0.5 * 1.0) -> True + st_slow = { + "score": 10.0, + "testcases": [{"time": 0.6}], + } + self.assertTrue(SolutionChecker.is_subtask_slow(st_slow, time_limit)) + + # Positive score and fast testcase -> False + st_fast = { + "score": 10.0, + "testcases": [{"time": 0.4}], + } + self.assertFalse(SolutionChecker.is_subtask_slow(st_fast, time_limit)) + + # Zero score with slow testcase -> False + st_zero = { + "score": 0.0, + "score_fraction": 0.0, + "testcases": [{"time": 0.8}], + } + self.assertFalse(SolutionChecker.is_subtask_slow(st_zero, time_limit)) + def test_check_single_subtask(self): st_ac = { "idx": 1, @@ -483,6 +507,167 @@ def test_check_single_subtask(self): self.assertIsNotNone(self.checker.check_single_subtask(st_ac, 1, "WrongAnswer")) self.assertIsNone(self.checker.check_single_subtask(st_ac, 1, None)) + def test_format_report_table(self): + time_limit = 1.0 + results = [ + { + "name": "sol_correct.cpp", + "criteria": { + "checks": ["Accepted", "Accepted"], + "min_score": 100.0, + "max_score": 100.0, + }, + "score": 100.0, + "compilation_failed": False, + "details": [ + { + "idx": 0, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.1}], + }, + { + "idx": 1, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.1}], + }, + ], + "failed": False, + "slow": False, + }, + { + "name": "sol_slow.cpp", + "criteria": { + "checks": ["Accepted", "Accepted"], + "min_score": 100.0, + "max_score": 100.0, + }, + "score": 100.0, + "compilation_failed": False, + "details": [ + { + "idx": 0, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.1}], + }, + { + "idx": 1, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.7}], + }, + ], + "failed": False, + "slow": True, + }, + { + "name": "sol_failed.cpp", + "criteria": { + "checks": ["Accepted", "WrongAnswer"], + "min_score": 50.0, + "max_score": 50.0, + }, + "score": 100.0, + "compilation_failed": False, + "details": [ + { + "idx": 0, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.1}], + }, + { + "idx": 1, + "score": 50.0, + "max_score": 50.0, + "testcases": [{"outcome": "Correct", "time": 0.1}], + }, + ], + "failed": True, + "slow": False, + }, + { + "name": "sol_compile_err.cpp", + "criteria": { + "checks": ["Accepted", "Accepted"], + "min_score": 100.0, + "max_score": 100.0, + }, + "score": 0.0, + "compilation_failed": True, + "details": None, + "failed": True, + "slow": False, + }, + { + "name": "arcari.cpp", + "criteria": { + "checks": [None, "Accepted", "Accepted"], + "min_score": 110.0, + "max_score": 120.0, + }, + "score": 71.0, + "compilation_failed": False, + "details": [ + { + "idx": 0, + "score": 0.0, + "max_score": 0.0, + "testcases": [{"outcome": "Correct", "time": 0.001}], + }, + { + "idx": 1, + "score": 20.0, + "max_score": 20.0, + "testcases": [{"outcome": "Correct", "time": 0.001}], + }, + { + "idx": 2, + "score": 51.0, + "max_score": 100.0, + "testcases": [{"outcome": "Correct", "time": 0.001}], + }, + ], + "failed": True, + "slow": False, + }, + ] + + # Formatted with color + table_colored = self.checker.format_report_table( + results, time_limit, use_color=True + ) + self.assertIn("sol_correct.cpp", table_colored) + self.assertIn("sol_slow.cpp", table_colored) + self.assertIn("sol_failed.cpp", table_colored) + self.assertIn("sol_compile_err.cpp", table_colored) + self.assertIn("arcari.cpp", table_colored) + # Verify expected score format + self.assertIn("71 (expected 110-120)", table_colored) + self.assertIn("100 (expected 50)", table_colored) + # Verify short status codes + self.assertIn("AC", table_colored) + self.assertIn("WA", table_colored) + self.assertIn("CE", table_colored) + # Verify ANSI colors + self.assertIn("\x1b[32;1m", table_colored) # Green + self.assertIn("\x1b[33;1m", table_colored) # Yellow + self.assertIn("\x1b[31;1m", table_colored) # Red + + # Formatted plain text (no ANSI codes) + table_plain = self.checker.format_report_table( + results, time_limit, use_color=False + ) + self.assertNotIn("\x1b[", table_plain) + self.assertIn("CE", table_plain) + self.assertIn("71 (expected 110-120)", table_plain) + self.assertIn("100", table_plain) + + # Empty results -> empty string + self.assertEqual(self.checker.format_report_table([], time_limit), "") + if __name__ == "__main__": unittest.main()