From 5e8ed081e386039781512db24ada3cf4fe0b5c41 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Wed, 5 Aug 2026 17:03:14 -0400 Subject: [PATCH] Fix personalized Parsons block generation and Java test execution - extract_parsons_code: scope HTML parsing to
 to exclude question prompt text from draggable blocks
- evaluate_fixed_code: switch Java test runner from main()-based to JUnitCore, matching livecode.js convention; add compileargs for student filename
- generate_parsons_blocks: one statement per block in split_java_code_into_blocks; preserve per-line ordering when flushing same-indent block stacks

Co-Authored-By: Claude Sonnet 4.6 
---
 bases/rsptx/book_server_api/routers/coach.py  | 16 ++++--
 .../evaluate_fixed_code.py                    | 54 ++++++-------------
 .../generate_parsons_blocks.py                | 37 +++++--------
 3 files changed, 41 insertions(+), 66 deletions(-)

diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py
index a4174614a..4024a5c51 100644
--- a/bases/rsptx/book_server_api/routers/coach.py
+++ b/bases/rsptx/book_server_api/routers/coach.py
@@ -108,11 +108,18 @@ def clean_python_testcase(raw_test_code: str) -> str:
 
 def extract_parsons_code(html_block):
     """
-    Given the full HTML/pre block for a Parsons problem extracted from DB,
-    return only the Parsons code part.
+    Given the full HTML for a Parsons question fetched from the DB, return only
+    the code inside 
...
-- the question prompt + text lives in a sibling
and must be excluded, + otherwise it gets treated as (and rendered as) one of the draggable blocks. """ + pre_match = re.search( + r']*class="parsonsblocks"[^>]*>(.*?)
', html_block, flags=re.DOTALL + ) + pre_content = pre_match.group(1) if pre_match else html_block + # Remove all HTML tags and extract the code lines - text = re.sub(r"<.*?>", "", html_block, flags=re.DOTALL) + text = re.sub(r"<.*?>", "", pre_content, flags=re.DOTALL) lines = text.strip().splitlines() if "-----" in lines: idx = lines.index("-----") @@ -123,7 +130,8 @@ def extract_parsons_code(html_block): clean_lines = [ line for line in code_lines if line.strip() and line.strip() != "=====" ] - return "\n".join(clean_lines) + result = "\n".join(clean_lines) + return result def _build_static_parsons_response( diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py b/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py index 4d93f0300..f81052e39 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py @@ -67,43 +67,15 @@ def _ensure_file_on_jobe( CHECK_PROXY = "/ns/rsproxy/jobeCheckFile/" -def inject_pass_fail_prints(test_code): - """ - Inserts System.out.println("PASS") before System.exit(0) - and System.out.println("FAIL") + message before System.exit(1), - inside the BackendTest main method. - - Assumes test_code contains: - public class BackendTest { public static void main(...) { ... } } - """ - - # Insert PASS before System.exit(0) if not already present - if 'System.out.println("PASS")' not in test_code: - test_code = re.sub( - r"(TestHelper\.runAllTests\(\);\s*)(System\.exit\(0\);)", - r'\1System.out.println("PASS");\n \2', - test_code, - ) - - # Insert FAIL prints before System.exit(1) inside catch(Exception e) - if 'System.out.println("FAIL")' not in test_code: - test_code = re.sub( - r"(catch\s*\(\s*Exception\s+e\s*\)\s*\{\s*)(System\.exit\(1\);)", - r'\1System.out.println("FAIL");\n System.out.println(e.getMessage());\n \2', - test_code, - ) - - return test_code - - # modified from rsproxy.py and livecode.js logic def load_and_run_java_tests(java_code, test_code): """ Compile and run Java code with test cases. Inputs: java_code (str): The Java code to be tested. - test_code (str): The Java test cases. The test code should contain a public class with a main method to run the tests. - The test code is automatically reformatted based on the unittest_code provided by instructors in the RST file. + test_code (str): The Java test cases -- a JUnit test class (extends CodeTestHelper, + methods annotated with @Test, no main()), matching the standard + Runestone suffix_code convention used for regular activecode runs. Output: bool: True if all tests pass, False otherwise. """ @@ -114,8 +86,6 @@ def extract_class_name(code): else: raise ValueError("Could not find a public class declaration.") - test_code = inject_pass_fail_prints(test_code) - print("modified_test_code\n", test_code) student_class = extract_class_name(java_code) test_class = extract_class_name(test_code) @@ -172,10 +142,16 @@ def extract_class_name(code): "body": put.text[:500], } - # JOBE runs this, and it calls test class main() - runner_code = f"""public class TestRunner {{ + # Mirrors livecode.js: run the JUnit test class via JUnitCore rather than + # calling a main() the test class doesn't have. + runner_code = f"""import org.junit.runner.JUnitCore; + import org.junit.runner.Result; + + public class TestRunner {{ public static void main(String[] args) {{ - {test_class}.main(args); + CodeTestHelper.resetFinalResults(); + Result result = JUnitCore.runClasses({test_class}.class); + System.out.println(result.wasSuccessful() ? "PASS" : "FAIL"); }} }}""" @@ -183,7 +159,11 @@ def extract_class_name(code): "language_id": "java", "sourcecode": runner_code, "sourcefilename": "", - "parameters": {}, + # RunestoneTests/CodeTestHelper only reference the student class by name + # (reflectively, at runtime), so javac never sees a static reference to it + # and won't compile it unless told to explicitly -- mirrors livecode.js, + # which pushes the student filename onto compileargs for the same reason. + "parameters": {"compileargs": [student_filename]}, "file_list": [ [student_id, student_filename], [test_id, test_filename], diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py index 5642c5387..7a44096a5 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py @@ -74,22 +74,16 @@ def break_and_indent(text, max_line_length, indent=4): def split_java_code_into_blocks(java_code): """ - Rule-based criteria to split Java code into Parsons blocks. - Aim to not make empty {} into individual blocks. + Rule-based criteria to split Java code into Parsons blocks: one statement per block. + Aim to not make empty {} into individual blocks -- an opening brace stays attached to + the header line above it (e.g. class/method declarations), and a closing brace stays + attached to the last statement inside the block it closes. """ lines = java_code.split("\n") blocks = [] i = 0 n = len(lines) - def get_indent(line): - m = re.match(r"^(\s*)", line) - return len(m.group(1)) if m else 0 - - def is_real_code(line): - s = line.strip() - return s and s not in ["{", "}"] - def is_open_brace(line): return line.strip() == "{" @@ -104,7 +98,6 @@ def is_close_brace(line): break block_lines = [] - base_indent = get_indent(lines[i]) # Add first line to block block_lines.append(lines[i]) @@ -115,12 +108,7 @@ def is_close_brace(line): block_lines.append(lines[i]) i += 1 - # Add all real code lines with same indentation - while i < n and get_indent(lines[i]) == base_indent and is_real_code(lines[i]): - block_lines.append(lines[i]) - i += 1 - - # If next line is `}`, add it to the last real code block + # If next line is `}`, add it to this block if i < n and is_close_brace(lines[i]): block_lines.append(lines[i]) i += 1 @@ -432,10 +420,10 @@ def aggregate_code_to_Parsons_block_with_distractor(blocks): elif (distractor_indent == "") & ( this_indent != current_indent_in_block_stack ): - # use the first line number of the block as the line sequence number - all_Parsons_blocks[block_stack[0][0]] = "".join( - str(block[1]) for block in block_stack - ) + # each accumulated same-indent line becomes its own Parsons block, + # keyed by its own position so ordering is preserved + for stacked_index, stacked_content in block_stack: + all_Parsons_blocks[stacked_index] = stacked_content block_stack = [(index, block[3])] current_indent_in_block_stack = this_indent # distractor_indent != "" means that detected that this is an end of a distractor block stack or a start of a distractor block stack -- how to distinguish? @@ -467,10 +455,9 @@ def aggregate_code_to_Parsons_block_with_distractor(blocks): if index == len(blocks) - 1: if distractor_indent == "": - # use the first line number of the block as the line sequence number - all_Parsons_blocks[block_stack[0][0]] = "".join( - str(block[1]) for block in block_stack - ) + # each accumulated same-indent line becomes its own Parsons block + for stacked_index, stacked_content in block_stack: + all_Parsons_blocks[stacked_index] = stacked_content elif distractor_indent != "": count_fixed = sum( 1 for block in block_stack if "#matched-fixed" in block[1]