diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py index 9b327bb0a..7d7103fb5 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py @@ -8,6 +8,7 @@ from .generate_parsons_blocks import generate_Parsons_block from .get_parsons_code_distractors import generate_code_with_distrator from .get_personalized_solution import get_example_solution, get_fixed_code +from .java_puzzle_blocks import restructure_java_puzzle from .personalize_parsons import compare_code, personalize_Parsons_block from .subgoal_labels import ( attach_labels_to_code, @@ -103,6 +104,11 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): final_Parsons_block, final_fixed_code, language ) + # One-line Java blocks leave the puzzle full of identical braces and of + # statements the line grader accepts in only one order, so regroup them. + if final_Parsons_block != "Correct_Code" and language == "java": + final_Parsons_block = restructure_java_puzzle(final_Parsons_block) + return ( final_fixed_code, final_Parsons_block, diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py new file mode 100644 index 000000000..c6ed5dc2f --- /dev/null +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py @@ -0,0 +1,206 @@ +""" +Regroup CodeTailor's Java Parsons puzzles into larger, less ambiguous blocks. + +CodeTailor builds most Java puzzles one line per block, so a puzzle is full of +identical ``{`` and ``}`` blocks and of statements whose order does not +matter (``this.name = name;`` / ``this.grade = grade;``), yet the line grader +accepts only one order. ``restructure_java_puzzle`` regroups a finished +puzzle so that: + +* an opening brace is in the same block as the header it opens, +* each closing brace is its own block, labeled with what it closes + (``} // end getPrice``, ``} // end if``, ``} // end class``), and +* consecutive statements at the same indentation share one block. A header + (``public int getPrice()``, ``if (...)``) always starts a new block. + +Paired distractors follow the block their correct lines end up in: the +distractor becomes a copy of that whole block with its own lines in place of +the correct ones. +""" + +import re + +_TAG_RE = re.compile(r"\s*#(paired|distractor)\s*$") +_CONTROL_RE = re.compile(r"(else\s+if|if|else|for|while|do|switch|try|catch|finally)\b") +_NAME_RE = re.compile(r"(\w+)\s*\(") +_CLASS_RE = re.compile(r"\bclass\s+(\w+)") + + +def _split_blocks(puzzle): + blocks, current = [], [] + for line in puzzle.split("\n"): + if line.strip() == "---": + blocks.append(current) + current = [] + else: + current.append(line) + blocks.append(current) + return [ + [ln.rstrip() for ln in b if ln.strip()] + for b in blocks + if any(ln.strip() for ln in b) + ] + + +def _tag(block): + match = _TAG_RE.search(block[-1]) + if not match: + return None, block + return match.group(1), block[:-1] + [block[-1][: match.start()].rstrip()] + + +def _indent(line): + return len(line) - len(line.lstrip()) + + +def _opens(line): + return line.strip().endswith("{") + + +def _closing_label(header, class_name): + """What a closing brace closes, from the header of the block it ends.""" + text = header.strip() + control = _CONTROL_RE.match(text.lstrip("} ").strip()) + if control: + word = re.sub(r"\s+", " ", control.group(1)) + return "end if" if word == "else if" else f"end {word}" + if _CLASS_RE.search(text): + return "end class" + name = _NAME_RE.search(text) + if name: + return ( + "end constructor" if name.group(1) == class_name else f"end {name.group(1)}" + ) + return None + + +def _brace_labels(lines): + """Map the index of each bare ``}`` line to its label.""" + class_match = next( + (_CLASS_RE.search(ln) for ln in lines if _CLASS_RE.search(ln)), None + ) + class_name = class_match.group(1) if class_match else None + labels, stack, last_header = {}, [], None + for i, line in enumerate(lines): + text = line.strip() + if text.startswith("}"): + header = stack.pop() if stack else None + if text == "}" and header: + label = _closing_label(header, class_name) + if label: + labels[i] = label + if _opens(line): # "} else {" + stack.append(text) + elif text == "{": + stack.append(last_header or "") + elif _opens(line): + stack.append(text) + if text not in ("{", "}"): + last_header = text + return labels + + +def restructure_java_puzzle(puzzle): + """Regroup a ``---``-separated Java puzzle (see the module docstring). + Puzzles with settled blocks are returned unchanged: their fixed lines + cannot share a block with lines the student has to place.""" + if "#settled" in puzzle: + return puzzle + blocks = _split_blocks(puzzle) + if not blocks: + return puzzle + + # Units: a solution block that has a paired distractor stays whole, so + # the distractor can stand in for it; any other block splits into lines. + solution, distractors, unpaired = [], {}, [] + for block in blocks: + tag, lines = _tag(block) + if tag == "paired" and solution: + distractors.setdefault(len(solution) - 1, []).append(lines) + elif tag: + unpaired.append(block) + else: + solution.append(lines) + split_units = [] + for idx, lines in enumerate(solution): + if idx not in distractors: + split_units.extend(([ln], None) for ln in lines) + continue + # Lines the block shares with all its distractors at either end (CodeTailor + # often pairs "}" / header / "{" with "}" / wrong header / "{") are not + # what the distractor is about, so they regroup like any other line. + variants = [lines] + distractors[idx] + pre = 0 + while ( + pre < min(map(len, variants)) - 1 + and len({v[pre].strip() for v in variants}) == 1 + ): + pre += 1 + suf = 0 + while ( + suf < min(map(len, variants)) - pre - 1 + and len({v[len(v) - 1 - suf].strip() for v in variants}) == 1 + ): + suf += 1 + distractors[idx] = [d[pre : len(d) - suf] for d in distractors[idx]] + split_units.extend(([ln], None) for ln in lines[:pre]) + split_units.append((lines[pre : len(lines) - suf], idx)) + split_units.extend(([ln], None) for ln in lines[len(lines) - suf :]) + + flat = [ln for lines, _ in split_units for ln in lines] + labels = _brace_labels(flat) + + # Group the units: [{"lines": [...], "units": [(start, lines, idx)]}] + groups, pos = [], 0 + for n, (lines, idx) in enumerate(split_units): + first = lines[0].strip() + next_first = ( + split_units[n + 1][0][0].strip() if n + 1 < len(split_units) else "" + ) + is_header = _opens(lines[-1]) or next_first == "{" + current = groups[-1] if groups else None + if first == "{" and current is not None and current["kind"] == "header": + current["units"].append((pos, lines, idx)) + current["kind"] = "opened" + elif first.startswith("}"): + text = lines[0] + if len(lines) == 1 and pos in labels and "//" not in text: + lines = [f"{text} // {labels[pos]}"] + groups.append( + { + "kind": "header" if _opens(lines[-1]) else "closed", + "units": [(pos, lines, idx)], + } + ) + elif is_header: + groups.append( + { + "kind": "opened" if _opens(lines[-1]) else "header", + "units": [(pos, lines, idx)], + } + ) + elif ( + current is not None + and current["kind"] == "statement" + and _indent(current["units"][-1][1][-1]) == _indent(lines[0]) + ): + current["units"].append((pos, lines, idx)) + else: + groups.append({"kind": "statement", "units": [(pos, lines, idx)]}) + pos += len(split_units[n][0]) + + out = [] + for group in groups: + correct = [ln for _, lines, _ in group["units"] for ln in lines] + out.append(correct) + for k, (_, _, idx) in enumerate(group["units"]): + for wrong in distractors.get(idx, []) if idx is not None else []: + swapped = [ + ln + for j, (_, lines, _) in enumerate(group["units"]) + for ln in (wrong if j == k else lines) + ] + swapped[-1] += " #paired" + out.append(swapped) + out.extend(unpaired) + return "\n---\n".join("\n".join(block) for block in out) + "\n" diff --git a/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py new file mode 100644 index 000000000..6ae0134ca --- /dev/null +++ b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py @@ -0,0 +1,194 @@ +""" +Tests for restructure_java_puzzle, which regroups CodeTailor's one-line-per-block +Java puzzles: opening braces join their header, closing braces are labeled, and +same-indentation statements share a block, with paired distractors following the +block their correct lines end up in. +""" + +from rsptx.book_server_api.routers.personalized_parsons.java_puzzle_blocks import ( + restructure_java_puzzle, +) + + +def _blocks(puzzle): + return [b.strip("\n") for b in puzzle.split("---")] + + +# CodeTailor's "Solution" puzzle for a constructor problem: one line per +# block, and a paired distractor built from the student's code. +_STUDENT = """\ + public class Student +--- + { +--- + private String name; +--- + private int grade; +--- + public Student(String name, int grade) +--- + { +--- + this.name = name; + this.grade = grade; +--- + name = name; + grade = grade; #paired +--- + } +--- + public static void main(String[] args) +--- + { +--- + Student s = new Student("Ana", 10); +--- + System.out.println(s.name); +--- + } +--- + } +""" + + +def test_braces_join_headers_and_closing_braces_are_labeled(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + assert blocks[0] == " public class Student\n {" + assert " public Student(String name, int grade)\n {" in blocks + assert " } // end constructor" in blocks + assert " } // end main" in blocks + assert blocks[-1] == " } // end class" + + +def test_same_indentation_statements_share_a_block(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + assert " private String name;\n private int grade;" in blocks + assert ( + ' Student s = new Student("Ana", 10);\n' + " System.out.println(s.name);" + ) in blocks + + +def test_paired_distractor_follows_its_block(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + correct = blocks.index( + " this.name = name;\n this.grade = grade;" + ) + assert blocks[correct + 1] == ( + " name = name;\n grade = grade; #paired" + ) + + +def test_distractor_is_swapped_into_a_merged_block(): + puzzle = """\ +public class A +--- +{ +--- + void f() +--- + { +--- + int x = 1; +--- + int y = 2; +--- + int y = 3; #paired +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + i = blocks.index(" int x = 1;\n int y = 2;") + assert blocks[i + 1] == " int x = 1;\n int y = 3; #paired" + assert " } // end f" in blocks + + +def test_lines_shared_with_a_distractor_regroup_normally(): + # CodeTailor paired the constructor's closing brace and the next method's + # header and brace with a distractor that differs only in the header. + puzzle = """\ +public class PizzaOrder +--- +{ +--- + public PizzaOrder(int slices) +--- + { +--- + this.slices = slices; +--- + } + public static void setPrice(double price) + { +--- + } + public void setPrice(double price) + { #paired +--- + pricePerSlice = price; +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + assert " } // end constructor" in blocks + i = blocks.index(" public static void setPrice(double price)\n {") + assert blocks[i + 1] == " public void setPrice(double price)\n { #paired" + assert " } // end setPrice" in blocks + + +def test_control_structures_are_labeled(): + puzzle = """\ +public class T +--- +{ +--- + public int getPrice() +--- + { +--- + if (age < 12) +--- + { +--- + return 5; +--- + } +--- + else +--- + { +--- + return 10; +--- + } +--- + for (int i = 0; i < 3; i++) +--- + { +--- + age++; +--- + } +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + assert " if (age < 12)\n {" in blocks + assert " else\n {" in blocks + assert blocks.count(" } // end if") == 1 + assert " } // end else" in blocks + assert " } // end for" in blocks + assert " } // end getPrice" in blocks + + +def test_settled_puzzles_are_unchanged(): + puzzle = ( + "public class A #settled\n---\n{ #settled\n---\n int x;\n---\n} #settled\n" + ) + assert restructure_java_puzzle(puzzle) == puzzle