From d24b38d5b0cbcc545123711bf5a355ac80cafac1 Mon Sep 17 00:00:00 2001 From: relhamdi Date: Fri, 22 May 2026 17:41:55 +0200 Subject: [PATCH] Add option parsing utils and tests --- src/utils.py | 64 +++++++++++++++++++++++++++++++++ tests/test_parse_options.py | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/test_parse_options.py diff --git a/src/utils.py b/src/utils.py index cb6e023..dbd3586 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,4 +1,5 @@ import re +from itertools import product def clean_prompt(text: str) -> str: @@ -13,3 +14,66 @@ def clean_prompt(text: str) -> str: text = re.sub(r"^\s*,\s*", "", text, flags=re.MULTILINE) # Leading comma text = re.sub(r"\n\s*\n", "\n", text) # Empty lines return text.strip() + + +def parse_options(raw: str) -> list[str]: + """ + Parse the options field into a flat list of options. + + Supports: + - Plain lines: direct options + - @combine / @end blocks: cartesian product of --- separated lists + - Empty lines: empty option (displayed as --) + - --- outside a block: treated as a literal option + """ + lines = raw.split("\n") + result = [] + in_combine = False + current_block: list[list[str]] = [] + current_list: list[str] = [] + + for i, line in enumerate(lines, 1): + stripped = line.strip() + + if stripped == "@combine": + if in_combine: + raise ValueError( + f"parse_options: @combine at line {i} opened inside an unclosed @combine block." + ) + in_combine = True + current_block = [] + current_list = [] + + elif stripped == "@end": + if not in_combine: + raise ValueError( + f"parse_options: @end at line {i} without matching @combine." + ) + # Save last list + current_block.append(current_list) + current_list = [] + in_combine = False + + # Generate cartesian product + for combo in product(*current_block): + result.append(", ".join(combo)) + + elif stripped == "---": + if in_combine: + # Separator between lists in a combine block + current_block.append(current_list) + current_list = [] + else: + # Outside a block, treat as literal option + result.append("---") + + else: + if in_combine: + current_list.append(line) + else: + result.append(line) + + if in_combine: + raise ValueError("parse_options: unclosed @combine block — missing @end.") + + return result diff --git a/tests/test_parse_options.py b/tests/test_parse_options.py new file mode 100644 index 0000000..34329c4 --- /dev/null +++ b/tests/test_parse_options.py @@ -0,0 +1,72 @@ +import pytest +from src.utils import parse_options + +# --- Plain options --- + + +def test_plain_options(): + result = parse_options("from front\nfrom behind") + assert result == ["from front", "from behind"] + + +def test_empty_line(): + result = parse_options("from front\n\nfrom behind") + assert result == ["from front", "", "from behind"] + + +def test_separator_outside_block(): + result = parse_options("from front\n---\nfrom behind") + assert result == ["from front", "---", "from behind"] + + +# --- Combine blocks --- + + +def test_combine_two_lists(): + raw = "before\n@combine\na\nb\n---\nc\nd\n@end\nafter" + result = parse_options(raw) + assert "before" in result + assert "after" in result + assert "a, c" in result + assert "a, d" in result + assert "b, c" in result + assert "b, d" in result + assert len(result) == 6 # before + 4 combos + after + + +def test_combine_three_lists(): + raw = "@combine\na\n---\nb\n---\nc\n@end" + result = parse_options(raw) + assert result == ["a, b, c"] + + +def test_combine_empty_line_in_list(): + raw = "@combine\na\n\n---\nb\n@end" + result = parse_options(raw) + assert ", b" in result # empty + b + assert "a, b" in result + + +def test_multiple_combine_blocks(): + raw = "@combine\na\n---\nb\n@end\n@combine\nc\n---\nd\n@end" + result = parse_options(raw) + assert "a, b" in result + assert "c, d" in result + + +# --- Validation errors --- + + +def test_combine_without_end(): + with pytest.raises(ValueError, match="unclosed @combine"): + parse_options("@combine\na\n---\nb") + + +def test_end_without_combine(): + with pytest.raises(ValueError, match="without matching @combine"): + parse_options("@end") + + +def test_nested_combine(): + with pytest.raises(ValueError, match="opened inside an unclosed"): + parse_options("@combine\na\n@combine\nb\n@end\n@end")