""" Step 9 — Statistics - test_stats_shows_creation_date: StatsView displays the database creation date - test_stats_shows_empty_when_no_date: StatsView shows empty message when no creation date - test_stats_date_format_english: Date is formatted as MM/DD/YYYY in English - test_stats_date_format_french: Date is formatted as DD/MM/YYYY in French - test_stats_date_format_updates_on_language_change: Switching language updates the date format reactively - test_stats_date_range_tabs_visible: All four date range tabs are rendered - test_stats_summary_cards_zero_when_no_data: Summary cards show 0 when no execution logs exist - test_stats_summary_cards_with_data: Summary cards show correct counts after seeding logs - test_stats_date_range_filter: Changing date range updates the sessions count - test_stats_frequency_no_data_message: Frequency no-data message shown when no sessions - test_stats_volume_no_data_message: Volume no-data message shown when no sessions with weight - test_stats_progression_no_data_message: Progression no-data shown before exercise selected - test_stats_frequency_chart_renders: Frequency chart canvas renders after seeding data - test_stats_volume_chart_renders: Volume chart canvas renders when sessions have weight data - test_stats_progression_chart_renders: Progression chart renders after selecting an exercise - test_stats_streak_consecutive_days: Streak counts consecutive days with sessions - test_stats_streak_ignores_aborted_sessions: Aborted sessions do not extend the streak """ from datetime import datetime, timedelta, timezone import pytest from playwright.sync_api import Page, expect _KNOWN_DATE = "2026-03-15T10:00:00.000Z" # ── Helpers ─────────────────────────────────────────────────────────────────── def _wait_for_db(page: Page, app_url: str): page.goto(app_url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def _set_date_and_reload(page: Page, app_url: str, date: str | None): _wait_for_db(page, app_url) page.evaluate("async () => { await window.__repos.settings.set('language', 'en') }") if date is None: page.evaluate("async () => { await window.__repos.settings.set('db_created_at', null) }") else: page.evaluate( f"async () => {{ await window.__repos.settings.set('db_created_at', '{date}') }}" ) page.wait_for_function( "window.__repos.settings.get('language').then(v => v === 'en')", timeout=5000, ) if date is None: page.wait_for_function( "window.__repos.settings.get('db_created_at')" ".then(v => v === null || v === undefined || v === '')", timeout=5000, ) else: page.wait_for_function( f"window.__repos.settings.get('db_created_at').then(v => v === '{date}')", timeout=5000, ) def _go_to_stats(page: Page): """Navigate to the stats page and wait for the view to finish loading.""" page.click('a[href="#/stats"]') page.wait_for_url("**/#/stats", timeout=5000) page.wait_for_selector('.stats-view', timeout=5000) # Wait for async onMounted to finish (cards rendered) page.wait_for_selector('[data-testid="stats-total-sessions"]', timeout=5000) def _iso_days_ago(days: int) -> str: """Return an ISO-8601 UTC timestamp for N days ago.""" dt = datetime.now(timezone.utc) - timedelta(days=days) return dt.strftime('%Y-%m-%dT%H:%M:%S.000Z') def _create_exercise(page: Page, title: str) -> str: return page.evaluate( f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') return window.__repos.exercises.create({{ title: {repr(title)}, description: '', asymmetric: false, alternate: false, image_urls: '[]', video_urls: '[]', default_reps: 10, default_weight: 0, created_by: userId }}) }}""" ) def _create_session(page: Page, title: str) -> str: return page.evaluate( f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') return window.__repos.sessions.create({{ title: {repr(title)}, description: '', created_by: userId }}) }}""" ) def _create_log_with_items( page: Page, session_id: str, exercise_id: str, started_at: str, reps: int = 10, weight: float = 0.0, completed: bool = True, status: str = 'completed', ) -> str: """Seed one execution log with one item.""" finished_at = started_at # same timestamp is fine for tests return page.evaluate( f"""async () => {{ const logId = await window.__repos.executionLogs.create({{ session_id: '{session_id}', started_at: '{started_at}', finished_at: '{finished_at}', status: {repr(status)} }}) await window.__repos.executionLogs.addItem({{ log_id: logId, exercise_id: '{exercise_id}', reps_done: {reps}, weight_used: {weight}, side: null, completed: {'true' if completed else 'false'}, timestamp: '{started_at}' }}) return logId }}""" ) def _delete_all_logs(page: Page): """Remove all execution logs (to reset state between tests).""" page.evaluate( """async () => { const logs = await window.__repos.executionLogs.getAll() for (const log of logs) { await window.__repos.executionLogs.delete(log.id) } }""" ) def _delete_all_exercises(page: Page): page.evaluate( """async () => { const items = await window.__repos.exercises.getAll() for (const item of items) { await window.__repos.exercises.delete(item.id) } }""" ) def _delete_all_sessions(page: Page): page.evaluate( """async () => { const items = await window.__repos.sessions.getAll() for (const item of items) { await window.__repos.sessions.delete(item.id) } }""" ) # ── Creation date tests (unchanged logic) ──────────────────────────────────── def test_stats_shows_creation_date(page: Page, app_url: str): """StatsView displays the database creation date.""" _set_date_and_reload(page, app_url, _KNOWN_DATE) _go_to_stats(page) creation_msg = page.locator('[data-testid="stats-using-since"]') expect(creation_msg).to_be_visible() text = creation_msg.text_content() assert 'You are using TrainUs since' in text, f"Expected creation date message, got: {text}" def test_stats_shows_empty_when_no_date(page: Page, app_url: str): """StatsView still loads when no creation date is set.""" _set_date_and_reload(page, app_url, None) _go_to_stats(page) assert page.locator('[data-testid="stats-using-since"]').count() == 0 # Restore a valid date for subsequent tests page.evaluate( f"async () => {{ await window.__repos.settings.set('db_created_at', '{_KNOWN_DATE}') }}" ) page.wait_for_function( f"window.__repos.settings.get('db_created_at').then(v => v === '{_KNOWN_DATE}')", timeout=5000, ) def test_stats_date_format_english(page: Page, app_url: str): """Date is formatted as MM/DD/YYYY in English.""" _set_date_and_reload(page, app_url, _KNOWN_DATE) _go_to_stats(page) text = page.locator('[data-testid="stats-using-since"]').text_content() assert '03/15/2026' in text, f"Expected English date format, got: {text}" def test_stats_date_format_french(page: Page, app_url: str): """Date is formatted as DD/MM/YYYY in French.""" _set_date_and_reload(page, app_url, _KNOWN_DATE) page.click('a[href="#/settings"]') page.wait_for_url("**/#/settings", timeout=5000) page.wait_for_selector('[data-testid="settings-language"]') page.select_option('[data-testid="settings-language"]', 'en') page.wait_for_timeout(300) page.select_option('[data-testid="settings-language"]', 'fr') page.wait_for_timeout(500) _go_to_stats(page) text = page.locator('[data-testid="stats-using-since"]').text_content() assert '15/03/2026' in text, f"Expected French date format, got: {text}" page.click('a[href="#/settings"]') page.wait_for_url("**/#/settings", timeout=5000) page.wait_for_selector('[data-testid="settings-language"]') page.select_option('[data-testid="settings-language"]', 'en') page.wait_for_timeout(300) def test_stats_date_format_updates_on_language_change(page: Page, app_url: str): """Switching language updates the date format reactively without navigation.""" _set_date_and_reload(page, app_url, _KNOWN_DATE) page.click('a[href="#/settings"]') page.wait_for_url("**/#/settings", timeout=5000) page.wait_for_selector('[data-testid="settings-language"]') page.select_option('[data-testid="settings-language"]', 'en') page.wait_for_timeout(300) _go_to_stats(page) text = page.locator('[data-testid="stats-using-since"]').text_content() assert '03/15/2026' in text, f"Expected English date format, got: {text}" page.click('a[href="#/settings"]') page.wait_for_url("**/#/settings", timeout=5000) page.wait_for_selector('[data-testid="settings-language"]') page.select_option('[data-testid="settings-language"]', 'fr') page.wait_for_timeout(500) _go_to_stats(page) text = page.locator('[data-testid="stats-using-since"]').text_content() assert '15/03/2026' in text, f"Expected French date format, got: {text}" page.click('a[href="#/settings"]') page.wait_for_url("**/#/settings", timeout=5000) page.wait_for_selector('[data-testid="settings-language"]') page.select_option('[data-testid="settings-language"]', 'en') page.wait_for_timeout(300) # ── New Step 9 tests ───────────────────────────────────────────────────────── def test_stats_date_range_tabs_visible(page: Page, app_url: str): """All four date range tabs are rendered on the stats page.""" _wait_for_db(page, app_url) _go_to_stats(page) for key in ('7d', '30d', '3m', 'all'): tab = page.locator(f'[data-testid="date-range-{key}"]') expect(tab).to_be_visible() # 30d should be active by default active = page.locator('[data-testid="date-range-30d"]') assert 'active' in (active.get_attribute('class') or ''), "30d tab should be active by default" def test_stats_summary_cards_zero_when_no_data(page: Page, app_url: str): """Summary cards show 0 when there are no execution logs.""" _wait_for_db(page, app_url) _delete_all_logs(page) _go_to_stats(page) sessions_card = page.locator('[data-testid="stats-total-sessions"] .stat-value') exercises_card = page.locator('[data-testid="stats-total-exercises"] .stat-value') streak_card = page.locator('[data-testid="stats-streak"] .stat-value') expect(sessions_card).to_have_text('0') expect(exercises_card).to_have_text('0') expect(streak_card).to_have_text('0') def test_stats_summary_cards_with_data(page: Page, app_url: str): """Summary cards reflect correct counts after seeding two execution logs.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Stats Push-up') sess_id = _create_session(page, 'Stats Session') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), reps=10, completed=True) _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(2), reps=8, completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) # Click "All time" so both logs are counted regardless of default 30d window page.click('[data-testid="date-range-all"]') page.wait_for_timeout(500) sessions_val = page.locator('[data-testid="stats-total-sessions"] .stat-value').text_content() exercises_val = page.locator('[data-testid="stats-total-exercises"] .stat-value').text_content() assert int(sessions_val) >= 2, f"Expected ≥2 sessions, got {sessions_val}" assert int(exercises_val) >= 2, f"Expected ≥2 exercises done, got {exercises_val}" def test_stats_date_range_filter(page: Page, app_url: str): """Switching to 7d hides logs older than 7 days; All time shows all.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Range Exercise') sess_id = _create_session(page, 'Range Session') # One log yesterday (within 7d), one log 20 days ago (outside 7d, inside 30d) _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), completed=True) _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(20), completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) # 30d should show 2 page.click('[data-testid="date-range-30d"]') page.wait_for_timeout(500) val_30d = page.locator('[data-testid="stats-total-sessions"] .stat-value').text_content() assert int(val_30d) >= 2, f"Expected ≥2 sessions in 30d, got {val_30d}" # 7d should show 1 page.click('[data-testid="date-range-7d"]') page.wait_for_timeout(500) val_7d = page.locator('[data-testid="stats-total-sessions"] .stat-value').text_content() assert int(val_7d) == 1, f"Expected 1 session in 7d, got {val_7d}" def test_stats_frequency_no_data_message(page: Page, app_url: str): """Frequency section shows a no-data message when no sessions exist.""" _wait_for_db(page, app_url) _delete_all_logs(page) _go_to_stats(page) nodata = page.locator('[data-testid="stats-frequency-nodata"]') expect(nodata).to_be_visible() assert page.locator('[data-testid="stats-frequency-chart"]').count() == 0 def test_stats_volume_no_data_message(page: Page, app_url: str): """Volume section shows a no-data message when no sessions exist.""" _wait_for_db(page, app_url) _delete_all_logs(page) _go_to_stats(page) nodata = page.locator('[data-testid="stats-volume-nodata"]') expect(nodata).to_be_visible() assert page.locator('[data-testid="stats-volume-chart"]').count() == 0 def test_stats_progression_no_data_message(page: Page, app_url: str): """Progression section shows select-exercise prompt before an exercise is picked.""" _wait_for_db(page, app_url) _go_to_stats(page) nodata = page.locator('[data-testid="stats-progression-nodata"]') expect(nodata).to_be_visible() assert page.locator('[data-testid="stats-progression-chart"]').count() == 0 def test_stats_frequency_chart_renders(page: Page, app_url: str): """Frequency chart canvas appears after seeding sessions in current period.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Freq Exercise') sess_id = _create_session(page, 'Freq Session') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) page.click('[data-testid="date-range-all"]') page.wait_for_timeout(500) chart = page.locator('[data-testid="stats-frequency-chart"]') expect(chart).to_be_visible() canvas = chart.locator('canvas') expect(canvas).to_be_visible() def test_stats_volume_chart_renders(page: Page, app_url: str): """Volume chart appears when sessions have weight data.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Vol Exercise') sess_id = _create_session(page, 'Vol Session') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), reps=10, weight=50.0, completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) page.click('[data-testid="date-range-all"]') page.wait_for_timeout(500) chart = page.locator('[data-testid="stats-volume-chart"]') expect(chart).to_be_visible() canvas = chart.locator('canvas') expect(canvas).to_be_visible() def test_stats_progression_chart_renders(page: Page, app_url: str): """Progression chart renders after selecting an exercise from the dropdown.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Prog Exercise') sess_id = _create_session(page, 'Prog Session') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), reps=12, weight=60.0, completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) page.click('[data-testid="date-range-all"]') page.wait_for_timeout(500) # The dropdown should have the exercise as an option select = page.locator('[data-testid="stats-progression-exercise"]') expect(select).to_be_visible() select.select_option(label='Prog Exercise') page.wait_for_timeout(500) chart = page.locator('[data-testid="stats-progression-chart"]') expect(chart).to_be_visible() canvas = chart.locator('canvas') expect(canvas).to_be_visible() def test_stats_streak_consecutive_days(page: Page, app_url: str): """Streak counter reflects consecutive days with at least one session.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Streak Exercise') sess_id = _create_session(page, 'Streak Session') # Three consecutive days ending today _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(0), completed=True) _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), completed=True) _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(2), completed=True) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content() assert int(streak_val) >= 3, f"Expected streak ≥ 3, got {streak_val}" def test_stats_streak_ignores_aborted_sessions(page: Page, app_url: str): """Aborted sessions do not extend the streak: one completed log today plus one aborted log yesterday must give a streak of 1, not 2.""" _wait_for_db(page, app_url) _delete_all_logs(page) _delete_all_exercises(page) _delete_all_sessions(page) ex_id = _create_exercise(page, 'Aborted Streak Exercise') sess_id = _create_session(page, 'Aborted Streak Session') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(0), status='completed') _create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), status='aborted') page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) _go_to_stats(page) streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content() assert int(streak_val) == 1, f"Expected streak 1 (aborted must not count), got {streak_val}"