Skip to content

Commit ab4198e

Browse files
feat(io): add output formatting normalization (#349) (#350)
1 parent d7ae6ba commit ab4198e

5 files changed

Lines changed: 267 additions & 18 deletions

File tree

autograder/template_library/input_output.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import logging
22
import re
3-
from typing import List, Optional, Dict, Any, Type
4-
5-
from pydantic import BaseModel, Field
3+
from typing import Optional
64

75
from autograder.models.abstract.template import Template
86
from autograder.models.abstract.test_function import TestFunction
97
from autograder.models.dataclass.param_description import ParamDescription
10-
from autograder.models.dataclass.submission import SubmissionFile
118
from autograder.models.dataclass.test_result import TestResult
12-
from autograder.models.dataclass.structural_analysis_result import StructuralAnalysisResult
139
from autograder.translations import t
1410
from sandbox_manager.sandbox_container import SandboxContainer
15-
from sandbox_manager.models.sandbox_models import Language, ResponseCategory
11+
from sandbox_manager.models.sandbox_models import ResponseCategory
1612

1713

1814
# ===============================================================
@@ -77,6 +73,17 @@ def check_for_base_errors(self, output, **kwargs) -> TestResult:
7773

7874
return None
7975

76+
@staticmethod
77+
def _normalize(text: str) -> str:
78+
"""
79+
Normalize text for output comparison by:
80+
1. Converting all line endings to \n
81+
2. Removing trailing whitespace from each line
82+
3. Removing leading and trailing whitespace from the entire output
83+
"""
84+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
85+
return "\n".join(line.rstrip() for line in lines).strip()
86+
8087
# ===============================================================
8188
# TestFunction for Input/Output Validation
8289
# ===============================================================
@@ -107,7 +114,8 @@ def parameter_description(self):
107114
return [
108115
ParamDescription("inputs", t("io.expect_output.params.inputs"), "list of strings"),
109116
ParamDescription("expected_output", t("io.expect_output.params.expected"), "string"),
110-
ParamDescription("program_command", t("io.expect_output.params.program_command"), "string or dict")
117+
ParamDescription("program_command", t("io.expect_output.params.program_command"), "string or dict"),
118+
ParamDescription("normalization", t("io.expect_output.params.normalization"), "boolean")
111119
]
112120

113121
def execute(self, files, sandbox: SandboxContainer, *args, **kwargs) -> TestResult:
@@ -117,6 +125,7 @@ def execute(self, files, sandbox: SandboxContainer, *args, **kwargs) -> TestResu
117125
inputs = kwargs.get("inputs")
118126
expected_output = kwargs.get("expected_output", "")
119127
program_command = kwargs.get("program_command")
128+
normalization = kwargs.get("normalization", True)
120129
locale = kwargs.get("locale")
121130

122131
try:
@@ -132,8 +141,12 @@ def execute(self, files, sandbox: SandboxContainer, *args, **kwargs) -> TestResu
132141
return error_result
133142

134143
# Standard I/O Comparison if execution succeeded
135-
actual_output = output.stdout.strip()
136-
expected = expected_output.strip()
144+
actual_output = output.stdout
145+
expected = expected_output
146+
147+
if normalization:
148+
actual_output = self._normalize(actual_output)
149+
expected = self._normalize(expected)
137150

138151
if actual_output == expected:
139152
return TestResult(
@@ -268,12 +281,6 @@ def _validate_artifact_path(artifact_path: str) -> Optional[str]:
268281
return f"Invalid artifact_path (absolute or traversal): {artifact_path}"
269282
return None
270283

271-
@staticmethod
272-
def _normalize(text: str) -> str:
273-
"""Normalize line endings and strip trailing whitespace per line."""
274-
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
275-
return "\n".join(line.rstrip() for line in lines).strip()
276-
277284
@staticmethod
278285
def _match(actual: str, expected: str, mode: str) -> bool:
279286
if mode == "contains":

autograder/translations/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@
435435
"params": {
436436
"inputs": "List of strings to be sent to the program, each on a new line.",
437437
"expected": "The single string the program must print to standard output.",
438-
"program_command": "(Optional) Command to run the program. Can be string (legacy), dict (multi-lang), or 'CMD' (auto-resolve)."
438+
"program_command": "(Optional) Command to run the program. Can be string (legacy), dict (multi-lang), or 'CMD' (auto-resolve).",
439+
"normalization": "Normalize whitespace (default: true)",
440+
"normalization_desc": "Strip trailing whitespace from each line and normalize line endings"
439441
},
440442
"report": {
441443
"success": "Success: Output matches expected values.",

autograder/translations/pt_br.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@
435435
"params": {
436436
"inputs": "Lista de strings a serem enviadas para o programa, cada uma em uma nova linha.",
437437
"expected": "A única string que o programa deve imprimir na saída padrão.",
438-
"program_command": "(Opcional) Comando para executar o programa. Pode ser uma string (legado), dict (multi-idioma), ou 'CMD' (auto-resolve)."
438+
"program_command": "(Opcional) Comando para executar o programa. Pode ser uma string (legado), dict (multi-idioma), ou 'CMD' (auto-resolve).",
439+
"normalization": "Normalizar espaços em branco (padrão: true)",
440+
"normalization_desc": "Remove espaços em branco no final de cada linha e normaliza quebras de linha"
439441
},
440442
"report": {
441443
"success": "Sucesso: A saída corresponde aos valores esperados.",

docs/templates/input_output.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,21 @@ Executes a program with a series of stdin inputs and compares the program's stdo
1919
| `inputs` | list[string] || List of strings sent to stdin, each on a new line |
2020
| `expected_output` | string || Exact string the program should print to stdout |
2121
| `program_command` | string || Command to execute. Can be a plain string (e.g., `"python main.py"`), a dict for multi-language support, or `"CMD"` for auto-resolution based on submission language. |
22+
| `normalization` | boolean || Normalize line endings and trim trailing whitespace (default: `true`) |
23+
24+
**Normalization (default: `true`):**
25+
When enabled, the comparison process:
26+
1. Converts all line endings (`\r\n`, `\r`, `\n`) to Unix-style `\n`
27+
2. Removes trailing whitespace from each line
28+
3. Removes leading and trailing whitespace from the entire output
29+
4. Then compares the normalized strings
30+
31+
This makes tests robust to:
32+
- Accidental trailing spaces in `print()` statements
33+
- Different line ending formats (Windows vs. Unix/Linux)
34+
- Mixed whitespace from copy-pasted expected outputs
2235

23-
**Scoring:** 100 if output matches exactly (after trimming whitespace), 0 otherwise.
36+
**Scoring:** 100 if output matches (after normalization), 0 otherwise.
2437

2538
**Error handling:** Automatically detects and reports:
2639
- Timeouts (infinite loops)

tests/unit/test_expect_output.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Unit tests for ExpectOutputTest.
3+
4+
Tests cover:
5+
- Exact match success
6+
- Exact match with normalization (trailing spaces)
7+
- Exact match without normalization
8+
- Mixed line endings (CRLF, LF, CR)
9+
- Timeout, compilation error, runtime error handling
10+
- Registration in InputOutputTemplate
11+
"""
12+
13+
import unittest
14+
from unittest.mock import MagicMock
15+
16+
from autograder.template_library.input_output import ExpectOutputTest, InputOutputTemplate
17+
from sandbox_manager.models.sandbox_models import CommandResponse, ResponseCategory
18+
19+
20+
def _make_sandbox(stdout="", stderr="", exit_code=0, category=ResponseCategory.SUCCESS):
21+
"""Return a mock sandbox whose run_commands returns the given CommandResponse."""
22+
sandbox = MagicMock()
23+
sandbox.run_commands.return_value = CommandResponse(
24+
stdout=stdout, stderr=stderr, exit_code=exit_code,
25+
execution_time=0.1, category=category,
26+
)
27+
sandbox.run_command.return_value = CommandResponse(
28+
stdout=stdout, stderr=stderr, exit_code=exit_code,
29+
execution_time=0.1, category=category,
30+
)
31+
return sandbox
32+
33+
34+
class TestExpectOutputNormalization(unittest.TestCase):
35+
"""Tests for whitespace normalization in ExpectOutputTest."""
36+
37+
def setUp(self):
38+
"""Set up the test case."""
39+
self.test = ExpectOutputTest()
40+
41+
def test_exact_match_success(self):
42+
"""Test success when output exactly matches expected output."""
43+
sandbox = _make_sandbox(stdout="hello world\n")
44+
45+
result = self.test.execute(
46+
files=None, sandbox=sandbox,
47+
inputs=[], expected_output="hello world",
48+
program_command="echo hello world",
49+
)
50+
51+
self.assertEqual(result.score, 100.0)
52+
53+
def test_normalization_strips_trailing_spaces(self):
54+
"""Test that normalization removes trailing spaces on each line."""
55+
sandbox = _make_sandbox(stdout="line1 \nline2 \n")
56+
57+
result = self.test.execute(
58+
files=None, sandbox=sandbox,
59+
inputs=[], expected_output="line1\nline2",
60+
program_command="python3 main.py",
61+
normalization=True,
62+
)
63+
64+
self.assertEqual(result.score, 100.0)
65+
66+
def test_normalization_handles_crlf(self):
67+
"""Test that normalization converts CRLF to LF."""
68+
sandbox = _make_sandbox(stdout="line1\r\nline2\r\n")
69+
70+
result = self.test.execute(
71+
files=None, sandbox=sandbox,
72+
inputs=[], expected_output="line1\nline2",
73+
program_command="python3 main.py",
74+
normalization=True,
75+
)
76+
77+
self.assertEqual(result.score, 100.0)
78+
79+
def test_normalization_handles_cr(self):
80+
"""Test that normalization converts CR to LF."""
81+
sandbox = _make_sandbox(stdout="line1\rline2\r")
82+
83+
result = self.test.execute(
84+
files=None, sandbox=sandbox,
85+
inputs=[], expected_output="line1\nline2",
86+
program_command="python3 main.py",
87+
normalization=True,
88+
)
89+
90+
self.assertEqual(result.score, 100.0)
91+
92+
def test_normalization_preserves_blank_lines(self):
93+
"""Test that normalization preserves intentional blank lines."""
94+
sandbox = _make_sandbox(stdout="line1\n\nline2\n")
95+
96+
result = self.test.execute(
97+
files=None, sandbox=sandbox,
98+
inputs=[], expected_output="line1\n\nline2",
99+
program_command="python3 main.py",
100+
normalization=True,
101+
)
102+
103+
self.assertEqual(result.score, 100.0)
104+
105+
def test_no_normalization_fails_on_trailing_space(self):
106+
"""Test that without normalization, trailing spaces matter."""
107+
sandbox = _make_sandbox(stdout="hello ")
108+
109+
result = self.test.execute(
110+
files=None, sandbox=sandbox,
111+
inputs=[], expected_output="hello",
112+
program_command="echo hello",
113+
normalization=False,
114+
)
115+
116+
self.assertEqual(result.score, 0.0)
117+
118+
def test_normalization_default_true(self):
119+
"""Test that normalization defaults to True."""
120+
sandbox = _make_sandbox(stdout="hello \n")
121+
122+
result = self.test.execute(
123+
files=None, sandbox=sandbox,
124+
inputs=[], expected_output="hello",
125+
program_command="echo hello",
126+
# normalization parameter omitted, should default to True
127+
)
128+
129+
self.assertEqual(result.score, 100.0)
130+
131+
def test_real_world_example_trailing_space_in_output(self):
132+
"""
133+
Real-world example from issue:
134+
Student output has trailing space, expected doesn't.
135+
"""
136+
student_output = "Personagem mais popular:\nCodigo: 101\nNome: Mickey\nFilme: Fantasia\nAno: 1928\nNota: 9.80 "
137+
expected = "Personagem mais popular:\nCodigo: 101\nNome: Mickey\nFilme: Fantasia\nAno: 1928\nNota: 9.80"
138+
139+
sandbox = _make_sandbox(stdout=student_output)
140+
141+
result = self.test.execute(
142+
files=None, sandbox=sandbox,
143+
inputs=[], expected_output=expected,
144+
program_command="python3 main.py",
145+
normalization=True,
146+
)
147+
148+
self.assertEqual(result.score, 100.0)
149+
150+
151+
class TestExpectOutputErrors(unittest.TestCase):
152+
"""Tests for error handling in ExpectOutputTest."""
153+
154+
def setUp(self):
155+
"""Set up the test case."""
156+
self.test = ExpectOutputTest()
157+
158+
def test_timeout(self):
159+
"""Test handling of program execution timeout."""
160+
sandbox = _make_sandbox(
161+
stderr="Execution timed out",
162+
category=ResponseCategory.TIMEOUT,
163+
)
164+
165+
result = self.test.execute(
166+
files=None, sandbox=sandbox,
167+
inputs=["test"], expected_output="expected",
168+
program_command="python3 main.py",
169+
)
170+
171+
self.assertEqual(result.score, 0.0)
172+
173+
def test_compilation_error(self):
174+
"""Test handling of compilation errors."""
175+
sandbox = _make_sandbox(
176+
stderr="SyntaxError: invalid syntax",
177+
category=ResponseCategory.COMPILATION_ERROR,
178+
)
179+
180+
result = self.test.execute(
181+
files=None, sandbox=sandbox,
182+
inputs=["test"], expected_output="expected",
183+
program_command="java Main",
184+
)
185+
186+
self.assertEqual(result.score, 0.0)
187+
188+
def test_runtime_error(self):
189+
"""Test handling of runtime errors."""
190+
sandbox = _make_sandbox(
191+
stderr="ZeroDivisionError: division by zero",
192+
category=ResponseCategory.RUNTIME_ERROR,
193+
)
194+
195+
result = self.test.execute(
196+
files=None, sandbox=sandbox,
197+
inputs=["test"], expected_output="expected",
198+
program_command="python3 main.py",
199+
)
200+
201+
self.assertEqual(result.score, 0.0)
202+
203+
204+
class TestExpectOutputMetadata(unittest.TestCase):
205+
"""Tests for metadata properties of ExpectOutputTest."""
206+
207+
def test_name(self):
208+
"""Test the name property."""
209+
self.assertEqual(ExpectOutputTest().name, "expect_output")
210+
211+
def test_normalization_in_parameter_descriptions(self):
212+
"""Test that normalization parameter is documented."""
213+
params = ExpectOutputTest().parameter_description
214+
names = [p.name for p in params]
215+
self.assertIn("normalization", names)
216+
217+
def test_registered_in_template(self):
218+
"""Test that the test is registered in InputOutputTemplate."""
219+
template = InputOutputTemplate()
220+
test = template.get_test("expect_output")
221+
self.assertIsInstance(test, ExpectOutputTest)
222+
223+
224+
if __name__ == "__main__":
225+
unittest.main()

0 commit comments

Comments
 (0)