513 lines
19 KiB
Python
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}"
|