trainUs/tests/test_step9_stats.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

513 lines
19 KiB
Python

"""
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
"""
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,
) -> 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}'
}})
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}"