Commit 8ed954c2 authored by Your Name's avatar Your Name

Fix match progression and sanitize SMTP defaults

parent 0ca2b9bb
......@@ -6,7 +6,7 @@ import time
import json
import logging
import threading
from datetime import datetime, timedelta, date
from datetime import datetime, timedelta, date, timezone
from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent
......@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
def utcnow():
return datetime.now(datetime.UTC).replace(tzinfo=None)
return datetime.now(timezone.utc).replace(tzinfo=None)
import random
......@@ -1069,6 +1069,25 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed canonical settlement for match {match.id}: {exc}")
raise
def _filter_active_winning_outcomes(self, match: MatchModel, winning_outcomes: List[str]) -> List[str]:
"""Keep only outcomes active on this match, plus the under/over result."""
if not match or not winning_outcomes:
return []
active_outcomes = set()
try:
active_outcomes = set(match.get_outcomes_dict().keys())
except Exception as exc:
logger.warning(f"Failed to determine active outcomes for match {getattr(match, 'id', None)}: {exc}")
filtered = []
for outcome in winning_outcomes:
if outcome in active_outcomes or outcome == match.under_over_result:
if outcome not in filtered:
filtered.append(outcome)
return filtered
def _get_outcome_coefficient_for_bet(self, match_id: int, outcome: str, session) -> float:
"""Get coefficient for a specific outcome from match outcomes.
......@@ -3705,9 +3724,6 @@ class GamesThread(ThreadedComponent):
# Send MATCH_DONE message with result
self._send_match_done(fixture_id, match_id, result)
# Send NEXT_MATCH message to advance to next match
self._send_next_match(fixture_id, match_id)
except Exception as e:
logger.error(f"Failed to handle PLAY_VIDEO_RESULTS_DONE message: {e}")
......@@ -3753,8 +3769,8 @@ class GamesThread(ThreadedComponent):
logger.info(f"🛡️ [SAFETY NET] Calling _ensure_all_bets_resolved for match {match_id}")
self._ensure_all_bets_resolved(match_id, result)
# NEXT_MATCH is now sent immediately in _handle_play_video_result_done
# to avoid the 2-second delay and ensure proper sequencing
# Advance only after the match has been fully finalized in the database.
self._send_next_match(fixture_id, match_id)
except Exception as e:
logger.error(f"Failed to handle MATCH_DONE message: {e}")
......@@ -4122,10 +4138,11 @@ class GamesThread(ThreadedComponent):
match.result = selected_result
logger.info(f"DEBUG _update_bet_results: Set match.result to '{selected_result}'")
# Set winning outcomes as JSON array in separate field
if extraction_winning_outcome_names:
match.winning_outcomes = json.dumps(extraction_winning_outcome_names)
logger.info(f"DEBUG _update_bet_results: Set match.winning_outcomes to {extraction_winning_outcome_names}")
# Set winning outcomes as JSON array in separate field, filtered to active outcomes only
filtered_winning_outcome_names = self._filter_active_winning_outcomes(match, extraction_winning_outcome_names)
if filtered_winning_outcome_names:
match.winning_outcomes = json.dumps(filtered_winning_outcome_names)
logger.info(f"DEBUG _update_bet_results: Set filtered match.winning_outcomes to {filtered_winning_outcome_names}")
else:
match.winning_outcomes = None
logger.info(f"DEBUG _update_bet_results: No winning outcomes, set match.winning_outcomes to None")
......
......@@ -33,6 +33,7 @@ class MatchTimerComponent(ThreadedComponent):
self.current_fixture_id: Optional[str] = None
self.current_match_id: Optional[int] = None
self.pending_match_id: Optional[int] = None # Match prepared by START_INTRO
self.last_completed_match_id: Optional[int] = None
# Synchronization
self._timer_lock = threading.RLock()
......@@ -45,6 +46,7 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.subscribe(self.name, MessageType.GAME_STARTED, self._handle_game_started)
self.message_bus.subscribe(self.name, MessageType.SCHEDULE_GAMES, self._handle_schedule_games)
self.message_bus.subscribe(self.name, MessageType.CUSTOM, self._handle_custom_message)
self.message_bus.subscribe(self.name, MessageType.MATCH_DONE, self._handle_match_done)
self.message_bus.subscribe(self.name, MessageType.NEXT_MATCH, self._handle_next_match)
self.message_bus.subscribe(self.name, MessageType.START_INTRO, self._handle_start_intro)
......@@ -115,6 +117,8 @@ class MatchTimerComponent(ThreadedComponent):
self._handle_schedule_games(message)
elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message)
elif message.type == MessageType.MATCH_DONE:
self._handle_match_done(message)
elif message.type == MessageType.NEXT_MATCH:
self._handle_next_match(message)
elif message.type == MessageType.START_INTRO:
......@@ -243,6 +247,9 @@ class MatchTimerComponent(ThreadedComponent):
logger.info(f"Received NEXT_MATCH message for fixture {fixture_id}, match {match_id}")
logger.info("Previous match completed - restarting timer for next interval")
with self._timer_lock:
self.last_completed_match_id = match_id
# Start timer first to ensure countdown is visible immediately
match_interval = self._get_match_interval()
self._start_timer(match_interval * 60, fixture_id)
......@@ -287,6 +294,37 @@ class MatchTimerComponent(ThreadedComponent):
except Exception as restart_e:
logger.error(f"Failed to restart timer after NEXT_MATCH error: {restart_e}")
def _handle_match_done(self, message: Message):
"""Handle MATCH_DONE message - trigger next-match progression."""
try:
fixture_id = message.data.get("fixture_id")
match_id = message.data.get("match_id")
result = message.data.get("result")
logger.info(f"Received MATCH_DONE message for fixture {fixture_id}, match {match_id}, result {result}")
if fixture_id is None or match_id is None:
logger.warning(f"MATCH_DONE missing required data: {message.data}")
return
with self._timer_lock:
self.last_completed_match_id = match_id
next_match_message = Message(
type=MessageType.NEXT_MATCH,
sender=self.name,
recipient=self.name,
data={
"fixture_id": fixture_id,
"match_id": match_id,
},
)
logger.info(f"Dispatching NEXT_MATCH for fixture {fixture_id}, match {match_id} after MATCH_DONE")
self._handle_next_match(next_match_message)
except Exception as e:
logger.error(f"Failed to handle MATCH_DONE message: {e}")
def _handle_start_intro(self, message: Message):
"""Handle START_INTRO message - store the match_id for later MATCH_START"""
try:
......@@ -446,9 +484,10 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.publish(start_intro_message)
# Set the pending match for timer expiration
with self._timer_lock:
self.pending_match_id = target_match.id
self.current_fixture_id = fixture_id
with self._timer_lock:
self.pending_match_id = target_match.id
self.current_fixture_id = fixture_id
self.last_completed_match_id = None
return {
"fixture_id": fixture_id,
......@@ -467,10 +506,12 @@ class MatchTimerComponent(ThreadedComponent):
def _find_next_match_in_list(self, matches: list) -> Optional[Any]:
"""Find the next match to start from a list of matches"""
last_completed_match_id = getattr(self, 'last_completed_match_id', None)
# Priority order: bet -> scheduled -> pending
for status in ['bet', 'scheduled', 'pending']:
for match in matches:
if match.status == status:
if match.status == status and match.id != last_completed_match_id:
return match
return None
......@@ -898,4 +939,4 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.publish(update_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to send timer update: {e}")
\ No newline at end of file
logger.error(f"Failed to send timer update: {e}")
......@@ -1271,6 +1271,8 @@ class RTSPStreamer(ThreadedComponent):
def _handle_play_video_result(self, message: Message):
"""Handle PLAY_VIDEO_RESULT message"""
try:
from ..database.models import MatchModel
logger.info("Handling PLAY_VIDEO_RESULT message")
fixture_id = message.data.get("fixture_id")
......@@ -1286,10 +1288,28 @@ class RTSPStreamer(ThreadedComponent):
if self.is_playing_match_video:
self.is_playing_match_video = False
filtered_winning_outcomes = winning_outcomes
if match_id and self.db_manager:
session = self.db_manager.get_session()
try:
match = session.query(MatchModel).filter_by(id=match_id).first()
if match:
try:
active_outcomes = set(match.get_outcomes_dict().keys())
except Exception:
active_outcomes = set()
filtered_winning_outcomes = [
outcome for outcome in winning_outcomes
if outcome in active_outcomes or outcome == match.under_over_result
]
finally:
session.close()
self._update_overlay({
'outcome': result,
'result': result,
'winningOutcomes': [{'outcome': o} for o in winning_outcomes],
'winningOutcomes': [{'outcome': o} for o in filtered_winning_outcomes],
'match_id': match_id,
'fixture_id': fixture_id,
'is_result_video': True
......@@ -1582,4 +1602,4 @@ class RTSPStreamer(ThreadedComponent):
logger.error(f"Result video not found for {self.current_result}")
except Exception as e:
logger.error(f"Failed to play result after OVER/UNDER: {e}")
\ No newline at end of file
logger.error(f"Failed to play result after OVER/UNDER: {e}")
......@@ -7657,6 +7657,80 @@ class Migration_069_RepairExtractionStatsFinancials(DatabaseMigration):
return True
class Migration_070_SeedSmtpEmailSettings(DatabaseMigration):
"""Seed SMTP email settings with safe disabled defaults."""
def __init__(self):
super().__init__("070", "Seed safe SMTP email settings")
def up(self, db_manager) -> bool:
try:
import json
timestamp_sql = get_current_timestamp_sql(db_manager)
insert_ignore = get_insert_ignore_sql(db_manager)
seeded_settings = {
'enabled': False,
'smtp_host': '',
'smtp_port': 587,
'smtp_username': '',
'smtp_password': '',
'smtp_from_email': '',
'smtp_from_name': 'MbetterClient',
'smtp_use_tls': 'tls',
'app_domain': '',
}
with db_manager.engine.connect() as conn:
conn.execute(text(f"""
{insert_ignore} INTO game_config
(config_key, config_value, value_type, description, is_system, created_at, updated_at)
VALUES (:config_key, :config_value, :value_type, :description, :is_system, {timestamp_sql}, {timestamp_sql})
"""), {
'config_key': 'email_settings',
'config_value': json.dumps(seeded_settings),
'value_type': 'json',
'description': 'Email/SMTP settings for user verification and password reset',
'is_system': False,
})
conn.execute(text("""
UPDATE game_config
SET config_value = :config_value,
value_type = :value_type,
description = :description,
is_system = :is_system,
updated_at = """ + timestamp_sql + """
WHERE config_key = :config_key
"""), {
'config_key': 'email_settings',
'config_value': json.dumps(seeded_settings),
'value_type': 'json',
'description': 'Email/SMTP settings for user verification and password reset',
'is_system': False,
})
conn.commit()
logger.info("Safe SMTP email settings seeded successfully")
return True
except Exception as e:
logger.error(f"Failed to seed safe SMTP email settings: {e}")
return False
def down(self, db_manager) -> bool:
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DELETE FROM game_config WHERE config_key = 'email_settings'"))
conn.commit()
logger.info("SMTP email settings removed")
return True
except Exception as e:
logger.error(f"Failed to remove SMTP email settings: {e}")
return False
MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(),
Migration_002_AddIndexes(),
......@@ -7727,6 +7801,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_067_FixMatchResults(),
Migration_068_ResetRedistributionBalance(),
Migration_069_RepairExtractionStatsFinancials(),
Migration_070_SeedSmtpEmailSettings(),
]
......
......@@ -4916,11 +4916,10 @@ class QtVideoPlayer(QObject):
self.window.current_match_video_filename is not None)
if currently_playing_match:
# Stop the current match video and play result video immediately
logger.info("Match video currently playing, stopping it and playing result video")
if self.window and hasattr(self.window, 'media_player'):
self.window.stop_playback()
self._play_result_video(result_video_info)
# Queue the result video and let the normal EndOfMedia path send PLAY_VIDEO_MATCH_DONE.
# Stopping playback here can bypass match completion flow and cause the same match to repeat.
logger.info("Match video currently playing, queueing result video until match video completes")
self.queued_result_video = result_video_info
else:
# Play result video immediately
logger.info("No match video playing, playing result video immediately")
......
......@@ -4,6 +4,7 @@ Flask web dashboard application for MbetterClient
import time
import logging
from datetime import datetime, date, timedelta, timezone
from pathlib import Path
from typing import Optional, Dict, Any, List
from flask import Flask, request, jsonify, render_template, redirect, url_for, session, g
......@@ -24,13 +25,26 @@ from ..utils.ssl_utils import get_ssl_certificate_paths, create_ssl_context
from flask_cors import CORS
from .auth import AuthManager
from .api import DashboardAPI
from .routes import main_bp, auth_bp, api_bp
from .routes import (
main_bp,
auth_bp,
api_bp,
compute_daily_report_summary,
load_email_settings,
send_email_message,
)
from .screen_cast_routes import screen_cast_bp
from .billing_api import BillingAPI, init_billing_routes
from ..billing.billing_engine import BillingEngine
from ..database.models import ConfigurationModel
from ..utils.timezone_utils import utc_to_venue_datetime
logger = logging.getLogger(__name__)
DAILY_REPORT_RECIPIENT = "stefy@aisbf.cloud"
DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY = "daily_report_email_last_success_date"
DAILY_REPORT_START_HOUR = 5
class WebDashboard(ThreadedComponent):
"""Flask web dashboard component"""
......@@ -68,6 +82,9 @@ class WebDashboard(ThreadedComponent):
self.notification_lock = threading.Lock()
self.waiting_clients: List[threading.Event] = [] # Events for waiting long-poll clients
self.waiting_clients_lock = threading.Lock()
self.last_daily_report_attempt_at: float = 0.0
self.daily_report_retry_interval_seconds: int = 20 * 60
self.daily_report_check_interval_seconds: int = 60
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
......@@ -455,6 +472,13 @@ class WebDashboard(ThreadedComponent):
daemon=True
)
server_thread.start()
report_thread = threading.Thread(
target=self._run_daily_report_email_loop,
name="DailyReportEmail",
daemon=True
)
report_thread.start()
# Message processing loop
while self.running and not self.shutdown_event.is_set():
......@@ -476,6 +500,8 @@ class WebDashboard(ThreadedComponent):
# Wait for server thread to finish (with timeout since it's daemon)
if server_thread and server_thread.is_alive():
server_thread.join(timeout=2.0)
if report_thread and report_thread.is_alive():
report_thread.join(timeout=2.0)
except Exception as e:
logger.error(f"WebDashboard run failed: {e}")
......@@ -505,6 +531,119 @@ class WebDashboard(ThreadedComponent):
else:
# Expected during shutdown
logger.debug(f"Server stopped during shutdown: {e}")
def _run_daily_report_email_loop(self):
"""Send previous-day report summary once per day with retry until success."""
logger.info("Daily report email loop started")
try:
while self.running and not self.shutdown_event.is_set():
try:
self._maybe_send_daily_report_email()
except Exception as e:
logger.error(f"Daily report email loop error: {e}")
self.shutdown_event.wait(self.daily_report_check_interval_seconds)
finally:
logger.info("Daily report email loop stopped")
def _maybe_send_daily_report_email(self):
"""Send yesterday's report if SMTP is enabled and it has not succeeded yet."""
if not self._is_daily_report_send_window_open():
return
now_ts = time.time()
if self.last_daily_report_attempt_at and (
now_ts - self.last_daily_report_attempt_at < self.daily_report_retry_interval_seconds
):
return
session = self.db_manager.get_session()
try:
email_settings = load_email_settings(session)
if not email_settings.get('enabled'):
return
recipient_email = DAILY_REPORT_RECIPIENT
target_date = date.today() - timedelta(days=1)
target_date_key = target_date.isoformat()
if self._get_last_daily_report_success_date(session) == target_date_key:
return
self.last_daily_report_attempt_at = now_ts
summary = compute_daily_report_summary(session, self.db_manager, target_date)
html_content = self._build_daily_report_email_html(summary)
subject = f"Daily report summary - {target_date_key}"
send_email_message(email_settings, recipient_email, subject, html_content)
self._set_last_daily_report_success_date(session, target_date_key)
logger.info(f"Daily report email sent successfully for {target_date_key} to {recipient_email}")
finally:
session.close()
def _is_daily_report_send_window_open(self) -> bool:
"""Allow daily report sending only from 5 AM venue-local time onward."""
now_utc = datetime.utcnow().replace(tzinfo=timezone.utc)
venue_now = utc_to_venue_datetime(now_utc, self.db_manager)
if venue_now is None:
return False
return venue_now.hour >= DAILY_REPORT_START_HOUR
def _get_last_daily_report_success_date(self, session) -> Optional[str]:
"""Read the persisted last successful daily report date."""
config = session.query(ConfigurationModel).filter_by(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY
).first()
if not config:
return None
value = config.get_typed_value()
if isinstance(value, str):
return value
return None
def _set_last_daily_report_success_date(self, session, target_date_key: str):
"""Persist the last successful daily report date."""
config = session.query(ConfigurationModel).filter_by(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY
).first()
if config:
config.value = target_date_key
config.value_type = 'string'
config.description = 'Last successful daily report email date'
config.is_system = True
config.updated_at = datetime.utcnow()
else:
config = ConfigurationModel(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY,
value=target_date_key,
value_type='string',
description='Last successful daily report email date',
is_system=True,
)
session.add(config)
session.commit()
def _build_daily_report_email_html(self, summary: Dict[str, Any]) -> str:
"""Build HTML for previous-day summary email."""
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 700px; margin: 0 auto; color: #222;">
<h2>Report Summary from the Day Before</h2>
<p>Date: <strong>{summary['date']}</strong></p>
<table style="border-collapse: collapse; width: 100%;">
<tr><td style="padding: 8px; border: 1px solid #ddd;">Number of Bets</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_bets']}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Number of Matches</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_matches']}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Total Payin</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_payin']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Total Payout</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_payout']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Business Balance</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['business_balance']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Shortfall Accumulated</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['shortfall_accumulated']:.2f}</td></tr>
</table>
</body>
</html>
"""
def _setup_ssl_error_suppression(self):
"""Setup logging filter to suppress expected SSL connection errors"""
......
This diff is collapsed.
......@@ -11,8 +11,15 @@ from openpyxl import load_workbook
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mbetterclient.web_dashboard import routes as routes_module
from mbetterclient.web_dashboard.routes import api_bp, is_bet_detail_winning, is_settled_bet_detail_winning
from mbetterclient.web_dashboard import app as app_module
from mbetterclient.web_dashboard.routes import api_bp, is_bet_detail_winning, is_settled_bet_detail_winning, get_active_winning_outcomes
from mbetterclient.qt_player.player import OverlayWebChannel
from mbetterclient.web_dashboard.app import (
WebDashboard,
DAILY_REPORT_RECIPIENT,
DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY,
)
from mbetterclient.core.match_timer import MatchTimerComponent
class DummyDetail:
......@@ -460,3 +467,145 @@ def test_qt_overlay_winning_outcomes_return_empty_without_settled_wins():
overlay = OverlayWebChannel(db_manager=DummyDbManager(OverlaySessionStub()))
assert overlay._get_winning_outcomes_from_database(10) == []
def test_daily_report_email_html_contains_requested_fields():
dashboard = WebDashboard.__new__(WebDashboard)
html = dashboard._build_daily_report_email_html({
'date': '2026-05-13',
'total_bets': 12,
'total_matches': 4,
'total_payin': 500.0,
'total_payout': 320.0,
'business_balance': 180.0,
'shortfall_accumulated': 45.0,
})
assert 'Report Summary from the Day Before' in html
assert 'Number of Bets' in html
assert 'Number of Matches' in html
assert 'Total Payin' in html
assert 'Total Payout' in html
assert 'Business Balance' in html
assert 'Shortfall Accumulated' in html
def test_daily_report_email_waits_for_retry_interval(monkeypatch):
dashboard = WebDashboard.__new__(WebDashboard)
dashboard.running = True
dashboard.shutdown_event = Mock()
dashboard.last_daily_report_attempt_at = 100.0
dashboard.daily_report_retry_interval_seconds = 1200
monkeypatch.setattr(app_module.time, 'time', lambda: 200.0)
monkeypatch.setattr(WebDashboard, '_is_daily_report_send_window_open', lambda self: True)
called = {'session': False}
class DummySessionLocal:
def close(self):
called['session'] = True
dashboard.db_manager = type('DbMgr', (), {'get_session': lambda self: DummySessionLocal()})()
dashboard.last_daily_report_success_date = None
dashboard._maybe_send_daily_report_email()
assert called['session'] is False
def test_daily_report_recipient_is_fixed_address():
assert DAILY_REPORT_RECIPIENT == 'stefy@aisbf.cloud'
def test_daily_report_success_date_is_persisted_in_config():
dashboard = WebDashboard.__new__(WebDashboard)
class DummyConfig:
def __init__(self, key, value, value_type='string', description='', is_system=False):
self.key = key
self.value = value
self.value_type = value_type
self.description = description
self.is_system = is_system
self.updated_at = None
def get_typed_value(self):
return self.value
class ConfigQuery:
def __init__(self, store):
self.store = store
self.key = None
def filter_by(self, **kwargs):
self.key = kwargs.get('key')
return self
def first(self):
return self.store.get(self.key)
class ConfigSession:
def __init__(self):
self.store = {}
self.committed = False
def query(self, model):
return ConfigQuery(self.store)
def add(self, config):
self.store[config.key] = config
def commit(self):
self.committed = True
session = ConfigSession()
app_module.ConfigurationModel = DummyConfig
dashboard._set_last_daily_report_success_date(session, '2026-05-13')
assert session.committed is True
assert session.store[DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY].value == '2026-05-13'
assert dashboard._get_last_daily_report_success_date(session) == '2026-05-13'
def test_daily_report_send_window_opens_at_5am(monkeypatch):
dashboard = WebDashboard.__new__(WebDashboard)
dashboard.db_manager = Mock()
class FakeVenueTime:
def __init__(self, hour):
self.hour = hour
monkeypatch.setattr(app_module, 'utc_to_venue_datetime', lambda now, db: FakeVenueTime(4))
assert dashboard._is_daily_report_send_window_open() is False
monkeypatch.setattr(app_module, 'utc_to_venue_datetime', lambda now, db: FakeVenueTime(5))
assert dashboard._is_daily_report_send_window_open() is True
def test_active_winning_outcomes_filter_inactive_combo_outcomes():
class MatchWithOutcomes:
result = 'KO1'
under_over_result = 'UNDER'
winning_outcomes = ['KO1', 'WIN1', 'X1', '12']
def get_outcomes_dict(self):
return {'KO1': 2.0, 'WIN1': 1.5}
outcomes = get_active_winning_outcomes(Mock(), MatchWithOutcomes())
assert outcomes == ['KO1', 'WIN1', 'UNDER']
def test_match_timer_skips_last_completed_match_when_selecting_next():
timer = MatchTimerComponent.__new__(MatchTimerComponent)
timer.last_completed_match_id = 10
match_done = type('Match', (), {'id': 10, 'status': 'pending'})()
match_next = type('Match', (), {'id': 11, 'status': 'pending'})()
selected = timer._find_next_match_in_list([match_done, match_next])
assert selected.id == 11
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment