""" 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()