From 67c0a8d4626d9c9b09832be49adf1f485a28c5f3 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 23 May 2026 15:49:04 +0200 Subject: [PATCH] refactor: consolidate prediction parsing --- AGENTS.md | 2 +- tests/test_admin_service.py | 4 +- tests/test_prediction_parser.py | 209 ++++++------------ .../commands/admin_panel/result_modals.py | 4 +- typer_bot/services/admin_service.py | 6 +- typer_bot/utils/__init__.py | 4 - typer_bot/utils/prediction_parser.py | 107 ++------- 7 files changed, 92 insertions(+), 244 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 719151e..4431341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ You are working on `TyperBot`, a Discord bot for football prediction leagues. - **Thread Predictions:** Users can post predictions in public threads under fixture announcements. - **Rate Limiting:** Thread predictions are rate-limited to 1 per second per user. Cooldown entries auto-expire after 1 hour. - **Async:** All database ops must be async (`aiosqlite`). -- **Parsing:** Use `utils.prediction_parser.parse_line_predictions` for all score parsing. Do NOT write ad-hoc regex. +- **Parsing:** Use `utils.prediction_parser.parse_prediction_lines` for all score parsing. Do NOT write ad-hoc regex. - **Logging:** Use `typer_bot.utils.logger.setup_logging()` early. Do not use `print()`. - **Timezones:** All datetime operations use timezone-aware objects. Use `utils.timezone.now()` instead of `datetime.now()`. Configure via `TZ` env var (default: `UTC`). - **Permissions:** Bot requires `Send Messages`, `Send Messages in Threads`, `Read Message History`, `Add Reactions`, `Create Public Threads`, and `Use Slash Commands`. diff --git a/tests/test_admin_service.py b/tests/test_admin_service.py index 9fd4e18..4ebcf70 100644 --- a/tests/test_admin_service.py +++ b/tests/test_admin_service.py @@ -242,7 +242,7 @@ async def test_replace_prediction_preserves_original_timing_metadata( fixture, updated_prediction, recalculation = await service.replace_prediction( fixture_id, "user-1", - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2", + "Team E - Team F 0-2\nTeam A - Team B 2-1\nTeam C - Team D 1-1", "admin-1", "111111", ) @@ -388,7 +388,7 @@ async def test_correct_results_recalculates_fixture_scores_and_standings( fixture, results, recalculation = await service.correct_results( fixture1_id, - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2", + "Team E - Team F 0-2\nTeam A - Team B 2-1\nTeam C - Team D 1-1", "111111", ) diff --git a/tests/test_prediction_parser.py b/tests/test_prediction_parser.py index 5bd02b5..a81060b 100644 --- a/tests/test_prediction_parser.py +++ b/tests/test_prediction_parser.py @@ -4,124 +4,22 @@ ascii_username, format_predictions_preview, format_standings, - parse_line_predictions, parse_prediction_lines, - parse_predictions, ) -class TestParsePredictions: - """Test suite for parse_predictions function.""" - - def test_basic_scores(self): - """Basic hyphen-separated scores.""" - predictions, errors = parse_predictions("2-1 1-0 2-2", expected_count=3) - assert predictions == ["2-1", "1-0", "2-2"] - assert not errors - - def test_colon_separators(self): - """Scores with colon separators.""" - predictions, errors = parse_predictions("2:1 1:0 2:2", expected_count=3) - assert predictions == ["2-1", "1-0", "2-2"] - assert not errors - - def test_mixed_separators(self): - """Mixed hyphen and colon separators.""" - predictions, errors = parse_predictions("2-1 1:0 2-2", expected_count=3) - assert predictions == ["2-1", "1-0", "2-2"] - assert not errors - - def test_extra_spaces(self): - """Scores with extra spaces around separators.""" - predictions, errors = parse_predictions("2 - 1 1 : 0", expected_count=2) - assert predictions == ["2-1", "1-0"] - assert not errors - - def test_comma_separation(self): - """Comma-separated scores.""" - predictions, errors = parse_predictions("2-1, 1-0, 2-2", expected_count=3) - assert predictions == ["2-1", "1-0", "2-2"] - assert not errors - - def test_random_newlines(self): - """Scores with random newlines mixed in.""" - predictions, errors = parse_predictions("2-1\n\n1-0\n2-2\n", expected_count=3) - assert predictions == ["2-1", "1-0", "2-2"] - assert not errors - - def test_leading_trailing_whitespace(self): - """Input with leading/trailing whitespace and indentation.""" - predictions, errors = parse_predictions(" 2-1 1-0 ", expected_count=2) - assert predictions == ["2-1", "1-0"] - assert not errors - - def test_double_digit_scores(self): - """Double-digit scores like 10-0.""" - predictions, errors = parse_predictions("10-0 0-10 12-12", expected_count=3) - assert predictions == ["10-0", "0-10", "12-12"] - assert not errors - - def test_wrong_count_error(self): - """Error when count doesn't match expected.""" - predictions, errors = parse_predictions("2-1 1-0", expected_count=3) - assert predictions == ["2-1", "1-0"] - assert len(errors) == 1 - assert "Expected 3 scores, found 2" in errors[0] - - def test_no_valid_scores(self): - """Input with no valid score patterns.""" - predictions, errors = parse_predictions("hello world test", expected_count=3) - assert predictions == [] - assert len(errors) == 1 - - -class TestParseLinePredictions: - """Test suite for parse_line_predictions function.""" - - def test_basic_line_parsing(self): - """Basic line-by-line parsing with game context.""" - input_text = "Team A vs Team B 2-1\nTeam C vs Team D 1-0" +class TestParsePredictionLines: + def test_full_prediction_maps_by_game_name(self): games = ["Team A vs Team B", "Team C vs Team D"] - predictions, errors = parse_line_predictions(input_text, games) - assert predictions == ["2-1", "1-0"] - assert not errors - - def test_score_at_end_of_line(self): - """Score must be at end of line - trailing text causes parse failure.""" - # Score at end works - input_text = "Team A vs Team B 2-1\nTeam B 1-0" - games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) - assert predictions == ["2-1", "1-0"] - assert not errors - - def test_trailing_text_fails(self): - """Text after score fails - parser expects score at line end.""" - input_text = "Team A 2-1 some comment\nTeam B 1-0" - games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) - assert predictions == ["1-0"] # Only second line parses - assert len(errors) == 1 - assert "Could not find score" in errors[0] - - def test_leading_indentation(self): - """Lines with leading whitespace/indentation.""" - input_text = " Team A 2-1\n Team B 1-0" - games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) - assert predictions == ["2-1", "1-0"] - assert not errors + predictions, game_indexes, errors = parse_prediction_lines( + "Team A vs Team B 2-1\nTeam C vs Team D 1-0", + games, + ) - def test_colon_separators_in_lines(self): - """Lines with colon separators.""" - input_text = "Team A 2:1\nTeam B 1:0" - games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) assert predictions == ["2-1", "1-0"] - assert not errors - + assert game_indexes == [0, 1] + assert errors == [] -class TestParsePredictionLines: def test_partial_mapping_by_game_name(self): games = ["Team A - Team B", "Team C - Team D", "Team E - Team F"] input_text = "Team C - Team D 1-1\nTeam E - Team F 0-2" @@ -144,6 +42,25 @@ def test_full_prediction_falls_back_to_positional_matching(self): assert game_indexes == [0, 1] assert errors == [] + def test_score_at_end_of_line(self): + games = ["Team A", "Team B"] + predictions, game_indexes, errors = parse_prediction_lines("Team A 2-1\nTeam B 1-0", games) + + assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] + assert errors == [] + + def test_trailing_text_fails(self): + games = ["Team A", "Team B"] + predictions, game_indexes, errors = parse_prediction_lines( + "Team A 2-1 some comment\nTeam B 1-0", games + ) + + assert predictions == [] + assert game_indexes == [] + assert len(errors) == 1 + assert "Could not find score" in errors[0] + def test_partial_requires_game_names(self): games = ["Team A - Team B", "Team C - Team D"] predictions, game_indexes, errors = parse_prediction_lines("2-1", games, allow_partial=True) @@ -175,125 +92,135 @@ def test_partial_mapping_preserves_team_names_starting_with_number(self): assert errors == [] def test_mixed_separators_in_lines(self): - """Lines with mixed separators.""" input_text = "Team A 2-1\nTeam B 1:0\nTeam C 2-2" games = ["Team A", "Team B", "Team C"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0", "2-2"] + assert game_indexes == [0, 1, 2] assert not errors + def test_double_digit_scores(self): + games = ["Team A", "Team B"] + predictions, game_indexes, errors = parse_prediction_lines( + "Team A 10-0\nTeam B 0:12", games + ) + + assert predictions == ["10-0", "0-12"] + assert game_indexes == [0, 1] + assert errors == [] + def test_missing_score_in_line(self): - """Line without a valid score at the end.""" input_text = "Team A 2-1\nTeam B no score here" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) - assert predictions == ["2-1"] + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) + assert predictions == [] + assert game_indexes == [] assert len(errors) == 1 assert "Could not find score" in errors[0] def test_wrong_line_count_error(self): - """Error when line count doesn't match game count.""" input_text = "Team A 2-1" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == [] + assert game_indexes == [] assert len(errors) == 1 assert "Expected 2 predictions, found 1" in errors[0] def test_extra_whitespace_in_lines(self): - """Lines with extra internal whitespace.""" input_text = "Team A 2 - 1\nTeam B 1 : 0 " games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] assert not errors def test_nullified_game_lowercase_x(self): - """Parse 'x' as nullified game marker.""" input_text = "Team A 2-1\nTeam B x" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "x"] + assert game_indexes == [0, 1] assert not errors def test_nullified_game_uppercase_x(self): - """Parse 'X' as nullified game marker (case insensitive).""" input_text = "Team A 2-1\nTeam B X" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "x"] + assert game_indexes == [0, 1] assert not errors def test_mixed_scores_and_nullified(self): - """Mix of regular scores and nullified games.""" input_text = "Team A 2-1\nTeam B x\nTeam C 0-0\nTeam D X" games = ["Team A", "Team B", "Team C", "Team D"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "x", "0-0", "x"] + assert game_indexes == [0, 1, 2, 3] assert not errors def test_nullified_with_whitespace(self): - """Nullified marker with trailing whitespace.""" input_text = "Team A x \nTeam B X " games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["x", "x"] + assert game_indexes == [0, 1] assert not errors def test_comma_separated_predictions(self): - """Comma-separated predictions.""" input_text = "Team A 2-1, Team B 1-0" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] assert not errors def test_mixed_comma_and_newline(self): - """Mixed comma and newline delimiters.""" input_text = "Team A 2-1, Team B 1-0\nTeam C 2-2" games = ["Team A", "Team B", "Team C"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0", "2-2"] + assert game_indexes == [0, 1, 2] assert not errors def test_comma_with_extra_whitespace(self): - """Comma-separated with extra whitespace.""" input_text = "Team A 2-1 , Team B 1-0 , Team C 2-2" games = ["Team A", "Team B", "Team C"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0", "2-2"] + assert game_indexes == [0, 1, 2] assert not errors def test_trailing_comma(self): - """Trailing comma should be handled gracefully.""" input_text = "Team A 2-1, Team B 1-0," games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] assert not errors def test_multiple_commas(self): - """Multiple consecutive commas should not create empty segments.""" input_text = "Team A 2-1,, Team B 1-0" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] assert not errors def test_comma_with_nullified_games(self): - """Comma-separated with nullified games.""" input_text = "Team A 2-1, Team B x, Team C 1-0" games = ["Team A", "Team B", "Team C"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "x", "1-0"] + assert game_indexes == [0, 1, 2] assert not errors def test_comma_with_colon_separator(self): - """Comma-separated with colon score separators.""" input_text = "Team A 2:1, Team B 1:0" games = ["Team A", "Team B"] - predictions, errors = parse_line_predictions(input_text, games) + predictions, game_indexes, errors = parse_prediction_lines(input_text, games) assert predictions == ["2-1", "1-0"] + assert game_indexes == [0, 1] assert not errors diff --git a/typer_bot/commands/admin_panel/result_modals.py b/typer_bot/commands/admin_panel/result_modals.py index 1d10d6e..1adb3a5 100644 --- a/typer_bot/commands/admin_panel/result_modals.py +++ b/typer_bot/commands/admin_panel/result_modals.py @@ -5,7 +5,7 @@ import discord from typer_bot.database import Database -from typer_bot.utils import get_admin_permission_error, parse_line_predictions +from typer_bot.utils import get_admin_permission_error, parse_prediction_lines from .base import _format_prediction_line @@ -107,7 +107,7 @@ async def on_submit(self, interaction: discord.Interaction): await interaction.response.send_message(permission_error, ephemeral=True) return - results, errors = parse_line_predictions(self.results_input.value, self.fixture["games"]) + results, _, errors = parse_prediction_lines(self.results_input.value, self.fixture["games"]) if errors: await interaction.response.send_message("\n".join(errors), ephemeral=True) return diff --git a/typer_bot/services/admin_service.py b/typer_bot/services/admin_service.py index adac30c..fcfb06a 100644 --- a/typer_bot/services/admin_service.py +++ b/typer_bot/services/admin_service.py @@ -11,7 +11,7 @@ PredictionDisappearedError, PredictionNotFoundError, ) -from typer_bot.utils import parse_line_predictions +from typer_bot.utils import parse_prediction_lines @dataclass(slots=True) @@ -190,7 +190,7 @@ async def replace_prediction( if existing_prediction is None: raise PredictionNotFoundError - predictions, errors = parse_line_predictions(prediction_lines, fixture["games"]) + predictions, _, errors = parse_prediction_lines(prediction_lines, fixture["games"]) if errors: raise ValueError("\n".join(errors)) @@ -265,7 +265,7 @@ async def correct_results( if fixture is None: raise FixtureNotFoundError - results, errors = parse_line_predictions(results_lines, fixture["games"]) + results, _, errors = parse_prediction_lines(results_lines, fixture["games"]) if errors: raise ValueError("\n".join(errors)) diff --git a/typer_bot/utils/__init__.py b/typer_bot/utils/__init__.py index 914e0af..401c893 100644 --- a/typer_bot/utils/__init__.py +++ b/typer_bot/utils/__init__.py @@ -15,9 +15,7 @@ format_fixture_results, format_predictions_preview, format_standings, - parse_line_predictions, parse_prediction_lines, - parse_predictions, ) from .prediction_submission import PredictionSubmission, build_prediction_submission from .scoring import ( @@ -38,9 +36,7 @@ "is_admin", "is_admin_member", "is_configured_admin", - "parse_predictions", "parse_prediction_lines", - "parse_line_predictions", "format_fixture_results", "format_predictions_preview", "format_standings", diff --git a/typer_bot/utils/prediction_parser.py b/typer_bot/utils/prediction_parser.py index fc0d485..d54f1ae 100644 --- a/typer_bot/utils/prediction_parser.py +++ b/typer_bot/utils/prediction_parser.py @@ -16,12 +16,22 @@ def parse_prediction_lines( *, allow_partial: bool = False, ) -> tuple[list[str], list[int], list[str]]: - """Parse prediction lines and map them to specific fixture rows. + """Parse prediction lines and map them to fixture rows. - Each non-empty line must end with a score like ``2:0`` or ``2-1``. When the - fixture label at the start of the line matches one of the provided games, the - prediction is mapped to that exact game row. Full submissions can still fall - back to positional matching for backward compatibility. + Each non-empty line must end with a score like ``2:0``/``2-1``, or cancelled + marker ``x``. Scores are normalized to ``home-away``. When a line starts with + an exact fixture label from ``games``, that prediction is mapped to the matching + fixture row. Otherwise, full submissions fall back to positional matching when + line count equals fixture count. Partial submissions must include fixture names. + + Args: + input_text: Raw text using newline or comma separators. + games: Fixture rows used for mapping and count validation. + allow_partial: Whether fewer predictions than fixture rows are accepted. + + Returns: + A tuple of fixture-ordered predictions, mapped fixture indexes, and errors. + Any parse or mapping error returns empty predictions and indexes. """ normalized = input_text.replace(",", "\n") lines = [line.strip() for line in normalized.split("\n") if line.strip()] @@ -37,7 +47,7 @@ def parse_prediction_lines( match = re.search(r"(\d+)\s*[-:]\s*(\d+)\s*$", stripped) if not match and not is_cancelled: errors.append( - f"Line {line_number}: Could not find score (expected format: '2:0' or '2-1')" + f"Line {line_number}: Could not find score (expected format: '2:0' or '2-1', or 'x' for cancelled games)" ) continue @@ -87,91 +97,6 @@ def parse_prediction_lines( return ordered_predictions, ordered_indexes, [] -def parse_predictions(input_text: str, expected_count: int = 9) -> tuple[list[str], list[str]]: - """Parse predictions from user input. - - Format agnostic: "2-1 1-0", "2:1, 1:0", "2 - 1". - Returns: (valid_predictions, errors) - """ - logger.debug(f"Parsing predictions: expected={expected_count}, input_length={len(input_text)}") - - normalized = input_text.replace(",", " ") - pattern = r"\s*(\d+)\s*[-:]\s*(\d+)\s*" - - predictions = [] - errors = [] - - matches = list(re.finditer(pattern, normalized)) - logger.debug(f"Found {len(matches)} score matches in input") - - for match in matches: - home = match.group(1) - away = match.group(2) - predictions.append(f"{home}-{away}") - - if len(predictions) != expected_count: - error_msg = f"Expected {expected_count} scores, found {len(predictions)}" - logger.warning(f"Prediction count mismatch: {error_msg}") - errors.append(error_msg) - else: - logger.debug(f"Successfully parsed {len(predictions)} predictions") - - return predictions, errors - - -def parse_line_predictions(input_text: str, games: list[str]) -> tuple[list[str], list[str]]: - """Parse predictions from user input with game context. - - Accepts newline-separated or comma-separated predictions. Each segment should - contain a score at the end in format like ``2:0`` or ``2-1``. Cancelled or - voided fixtures can be marked with ``x`` and numeric scores are normalized to - ``home-away`` output regardless of the input separator. - - Args: - input_text: Raw input text (supports commas or newlines as delimiters) - games: Fixture game list used to validate line count and build errors - - Returns: (valid_predictions, errors) - """ - normalized = input_text.replace(",", "\n") - lines = [line.strip() for line in normalized.split("\n") if line.strip()] - - logger.debug(f"Parsing line predictions: {len(lines)} lines, {len(games)} games") - - predictions = [] - errors = [] - - if len(lines) != len(games): - error_msg = f"Expected {len(games)} predictions, found {len(lines)}" - logger.warning(f"Line count mismatch: {error_msg}") - errors.append(error_msg) - return predictions, errors - - for i, line in enumerate(lines): - stripped = line.strip() - - if re.search(r"[xX]\s*$", stripped): - predictions.append("x") - logger.debug(f"Line {i + 1}: Parsed nullified game (x)") - continue - - match = re.search(r"(\d+)\s*[-:]\s*(\d+)\s*$", stripped) - if match: - home_score = match.group(1) - away_score = match.group(2) - predictions.append(f"{home_score}-{away_score}") - logger.debug(f"Line {i + 1}: Parsed {home_score}-{away_score}") - else: - error_msg = f"Line {i + 1}: Could not find score (expected format: '2:0' or '2-1', or 'x' for cancelled games)" - logger.warning(f"Parse error on line {i + 1}: '{line[:50]}...'") - errors.append(error_msg) - - if not errors: - logger.debug(f"Successfully parsed all {len(predictions)} line predictions") - - return predictions, errors - - def ascii_username(username: str, max_len: int = 20) -> str: """Filter username to ASCII-only for reliable alignment in Discord code blocks.""" ascii_only = "".join(c for c in username if ord(c) < 128)