Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions tests/test_admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -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",
)

Expand Down
209 changes: 68 additions & 141 deletions tests/test_prediction_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions typer_bot/commands/admin_panel/result_modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions typer_bot/services/admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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))

Expand Down
Loading
Loading