trainUs/tests/test_step3_exercises.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

653 lines
23 KiB
Python

"""
Step 3 — Exercise Management Tests
- test_exercise_list_empty: Shows empty state when no exercises exist
- test_create_exercise_minimal: Create exercise with only required field (title)
- test_create_exercise_full: Create exercise with all fields filled
- test_exercise_appears_in_list: Created exercise appears in the list with badges
- test_exercise_detail_view: Detail view shows all exercise data
- test_edit_exercise: Edit an exercise and verify changes
- test_delete_exercise: Delete an exercise with confirmation
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
- test_search_exercises: Search filters exercises by title
- test_search_no_results: Search with no matches shows empty message
- test_search_clear_button: Clear button empties the search field and restores the full list
- test_validation_title_required: Empty title shows validation error
- test_validation_negative_reps: Negative reps shows validation error
- test_validation_negative_weight: Negative weight shows validation error
- test_validation_invalid_url: Invalid URL format is rejected
- test_duplicate_title_rejected: Creating exercise with duplicate title shows error
- test_asymmetric_toggle: Asymmetric toggle shows alternation options
- test_action_bar_buttons: New and Search buttons appear in action bar
"""
from playwright.sync_api import Page, expect
def _wait_for_db(page: Page, app_url: str):
"""Navigate and wait for the database to be ready."""
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def _go_to_exercises(page: Page, app_url: str):
"""Navigate to the exercises list and wait for it to load."""
_wait_for_db(page, app_url)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
def _clean_exercises(page: Page):
"""Remove all exercises via the repository."""
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) {
await window.__repos.exercises.delete(ex.id);
}
}"""
)
def _create_exercise_via_ui(page: Page, title, description="", reps=0, weight=0,
asymmetric=False, alternate=False,
image_urls=None, video_urls=None):
"""Fill the exercise form and save via UI."""
# Click New button in action bar
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
# Fill title
page.fill('[data-testid="exercise-title"]', title)
# Fill description
if description:
page.fill('[data-testid="exercise-description"]', description)
# Toggle asymmetric
if asymmetric:
page.click('[data-testid="exercise-asymmetric"]')
if alternate:
page.click('[data-testid="exercise-alternate-true"]')
else:
page.click('[data-testid="exercise-alternate-false"]')
# Fill reps
if reps:
page.fill('[data-testid="exercise-reps"]', str(reps))
# Fill weight
if weight:
page.fill('[data-testid="exercise-weight"]', str(weight))
# Add image URLs
if image_urls:
for url in image_urls:
page.fill('[data-testid="exercise-new-image-url"]', url)
page.click('[data-testid="exercise-add-image-url"]')
# Add video URLs
if video_urls:
for url in video_urls:
page.fill('[data-testid="exercise-new-video-url"]', url)
page.click('[data-testid="exercise-add-video-url"]')
# Save
page.click('[data-testid="exercise-save"]')
# Wait for navigation to detail view
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
def test_exercise_list_empty(page: Page, app_url: str):
"""Empty state is shown when no exercises exist."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()
def test_create_exercise_minimal(page: Page, app_url: str):
"""Create an exercise with only the required title field."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(page, "Push-ups")
# Should be on detail view
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Push-ups")
# Clean up
_clean_exercises(page)
def test_create_exercise_full(page: Page, app_url: str):
"""Create an exercise with all fields filled."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(
page,
title="Dumbbell Curl",
description="Bicep curl with dumbbell",
reps=12,
weight=10,
asymmetric=True,
alternate=True,
image_urls=["https://example.com/curl.jpg"],
video_urls=["https://www.youtube.com/watch?v=dQw4w9WgXcQ"],
)
# Verify detail page
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Dumbbell Curl")
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
"Bicep curl with dumbbell"
)
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("12")
expect(page.locator('[data-testid="exercise-detail-weight"]')).to_contain_text("10")
expect(page.locator('[data-testid="exercise-detail-asymmetric"]')).to_be_visible()
# Clean up
_clean_exercises(page)
def test_exercise_appears_in_list(page: Page, app_url: str):
"""Created exercise appears in the list view with correct info."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(page, title="Squats", reps=15, weight=50)
# Go back to list
page.click('[data-testid="exercise-back-btn"]')
page.wait_for_timeout(500)
# Exercise card should be visible
card_title = page.locator('[data-testid="exercise-card-title"]')
expect(card_title.first).to_have_text("Squats")
# Badges
expect(page.locator('[data-testid="exercise-card-reps"]').first).to_contain_text("15")
expect(page.locator('[data-testid="exercise-card-weight"]').first).to_contain_text("50")
# Clean up
_clean_exercises(page)
def test_exercise_detail_view(page: Page, app_url: str):
"""Detail view shows all exercise data correctly."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
# Create via repo
exercise_id = page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
return window.__repos.exercises.create({
title: 'Bench Press',
description: 'Chest exercise with barbell',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 8,
default_weight: 60,
created_by: uuid
});
}"""
)
# Navigate to detail
page.goto(f"{app_url}/#/exercises/{exercise_id}")
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Bench Press")
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
"Chest exercise with barbell"
)
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("8")
expect(page.locator('[data-testid="exercise-detail-weight"]')).to_contain_text("60")
# Clean up
_clean_exercises(page)
def test_edit_exercise(page: Page, app_url: str):
"""Edit an exercise and verify changes are persisted."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(page, title="Plank", reps=1, weight=0)
# Click edit button
page.click('[data-testid="exercise-edit-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
# Change title and reps
page.fill('[data-testid="exercise-title"]', "Side Plank")
page.fill('[data-testid="exercise-reps"]', "3")
page.fill('[data-testid="exercise-description"]', "Hold for 30 seconds each side")
# Save
page.click('[data-testid="exercise-save"]')
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
# Verify changes
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Side Plank")
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
"Hold for 30 seconds each side"
)
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("3")
# Clean up
_clean_exercises(page)
def test_delete_exercise(page: Page, app_url: str):
"""Delete an exercise with confirmation dialog."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(page, title="To Delete")
# Accept the confirm dialog
page.on("dialog", lambda dialog: dialog.accept())
# Click delete button
page.click('[data-testid="exercise-delete-btn"]')
page.wait_for_timeout(500)
# Should be back on list with empty state
expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()
def test_delete_exercise_cancel(page: Page, app_url: str):
"""Cancelling the delete dialog does not remove the exercise."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_create_exercise_via_ui(page, title="Keep Me")
# Dismiss the confirm dialog
page.on("dialog", lambda dialog: dialog.dismiss())
# Click delete button
page.click('[data-testid="exercise-delete-btn"]')
page.wait_for_timeout(500)
# Exercise should still be shown
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Keep Me")
# Clean up
_clean_exercises(page)
def test_search_exercises(page: Page, app_url: str):
"""Search filters exercises by title."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
# Create multiple exercises via repo
page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
await window.__repos.exercises.create({
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
created_by: uuid
});
await window.__repos.exercises.create({
title: 'Pull-ups', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 8, default_weight: 0,
created_by: uuid
});
await window.__repos.exercises.create({
title: 'Squats', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 15, default_weight: 0,
created_by: uuid
});
}"""
)
# Reload exercises list
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
# All 3 should be visible
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(3)
# Toggle search
page.click('[data-testid="exercise-search-toggle"]')
page.wait_for_selector('[data-testid="exercise-search-input"]')
# Type search query
page.fill('[data-testid="exercise-search-input"]', "ups")
page.wait_for_timeout(500)
# Only Push-ups and Pull-ups should be visible
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2)
# Clean up
_clean_exercises(page)
def test_search_no_results(page: Page, app_url: str):
"""Search with no matches shows no-results message."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
# Create one exercise
page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
await window.__repos.exercises.create({
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
created_by: uuid
});
}"""
)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
# Toggle search and type non-matching query
page.click('[data-testid="exercise-search-toggle"]')
page.fill('[data-testid="exercise-search-input"]', "zzzznotfound")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-no-results"]')).to_be_visible()
# Clean up
_clean_exercises(page)
def test_search_clear_button(page: Page, app_url: str):
"""Clear button empties the search field and restores the full list."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
await window.__repos.exercises.create({
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
created_by: uuid
});
await window.__repos.exercises.create({
title: 'Squats', description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 15, default_weight: 0,
created_by: uuid
});
}"""
)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('[data-testid="exercise-search-toggle"]')
# Clear button is hidden until there is text in the field
expect(page.locator('[data-testid="exercise-search-clear"]')).not_to_be_visible()
page.fill('[data-testid="exercise-search-input"]', "Push")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(1)
# Clicking the clear button empties the field and restores the full list
page.click('[data-testid="exercise-search-clear"]')
expect(page.locator('[data-testid="exercise-search-input"]')).to_have_value("")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2)
# Search field keeps focus after clearing
expect(page.locator('[data-testid="exercise-search-input"]')).to_be_focused()
# Clean up
_clean_exercises(page)
def test_validation_title_required(page: Page, app_url: str):
"""Empty title shows validation error and prevents save."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
# Click New
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
# Leave title empty, click Save
page.click('[data-testid="exercise-save"]')
# Error should appear
expect(page.locator('[data-testid="exercise-title-error"]')).to_be_visible()
# Should still be on the form (not navigated away)
expect(page.locator('[data-testid="exercise-title"]')).to_be_visible()
def test_validation_negative_reps(page: Page, app_url: str):
"""Negative reps shows validation error."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
page.fill('[data-testid="exercise-title"]', "Test Exercise")
page.fill('[data-testid="exercise-reps"]', "-5")
page.click('[data-testid="exercise-save"]')
expect(page.locator('[data-testid="exercise-reps-error"]')).to_be_visible()
def test_validation_negative_weight(page: Page, app_url: str):
"""Negative weight shows validation error."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
page.fill('[data-testid="exercise-title"]', "Test Exercise")
page.fill('[data-testid="exercise-weight"]', "-10")
page.click('[data-testid="exercise-save"]')
expect(page.locator('[data-testid="exercise-weight-error"]')).to_be_visible()
def test_validation_invalid_url(page: Page, app_url: str):
"""Invalid URL format is rejected when adding an image URL."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
# Try adding an invalid URL
page.fill('[data-testid="exercise-new-image-url"]', "not-a-url")
page.click('[data-testid="exercise-add-image-url"]')
expect(page.locator('[data-testid="exercise-image-url-error"]')).to_be_visible()
def test_duplicate_title_rejected(page: Page, app_url: str):
"""Creating an exercise with a duplicate title shows an error."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
# Create first exercise
_create_exercise_via_ui(page, title="Unique Exercise")
# Go back to list
page.click('[data-testid="exercise-back-btn"]')
page.wait_for_timeout(500)
# Try to create another with the same title
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
page.fill('[data-testid="exercise-title"]', "Unique Exercise")
page.click('[data-testid="exercise-save"]')
page.wait_for_timeout(500)
# Should show error (title error or save error)
title_error_visible = page.locator('[data-testid="exercise-title-error"]').is_visible()
save_error_visible = page.locator('[data-testid="exercise-save-error"]').is_visible()
assert title_error_visible or save_error_visible, "Expected duplicate title error"
# Clean up
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
_clean_exercises(page)
def test_asymmetric_toggle(page: Page, app_url: str):
"""Asymmetric toggle shows/hides alternation radio buttons."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('[data-testid="exercise-new-btn"]')
page.wait_for_selector('[data-testid="exercise-title"]')
# Alternate options should not be visible initially
expect(page.locator('[data-testid="exercise-alternate-true"]')).not_to_be_visible()
expect(page.locator('[data-testid="exercise-alternate-false"]')).not_to_be_visible()
# Toggle asymmetric
page.click('[data-testid="exercise-asymmetric"]')
# Alternate options should now be visible
expect(page.locator('[data-testid="exercise-alternate-true"]')).to_be_visible()
expect(page.locator('[data-testid="exercise-alternate-false"]')).to_be_visible()
# Untoggle asymmetric
page.click('[data-testid="exercise-asymmetric"]')
# Alternate options should be hidden again
expect(page.locator('[data-testid="exercise-alternate-true"]')).not_to_be_visible()
def test_action_bar_buttons(page: Page, app_url: str):
"""New and Search buttons are visible in the action bar on the exercise list."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-new-btn"]')).to_be_visible()
expect(page.locator('[data-testid="exercise-search-toggle"]')).to_be_visible()