""" Step 20 — LIKE Wildcard Escaping Tests (R10) + 404 Catch-all (R12) + Delete History (R15) + Cross-user Settings (R14) R10 — LIKE wildcard escaping: - test_underscore_matches_only_underscore: `Push_up` is found by `Push_`, not by `PushXup` - test_percent_does_not_match_all: `100%` search does NOT match `100kg squat` - test_normal_search_still_works: Regular substring search still finds results - test_percent_sign_in_title_is_found: Exercise with `%` in title is found by searching `%` R12 — 404 catch-all route: - test_unknown_hash_redirects_to_home: Navigating to a typo'd hash URL lands on the Home view R14 — Cross-user import does not apply settings: - test_different_user_import_skips_settings: Importing a different-user envelope leaves local settings unchanged - test_same_user_import_applies_settings: Importing a same-user envelope applies settings R15 — Delete with history count: - test_delete_exercise_with_history_shows_count: Confirm dialog mentions journal entry count - test_delete_session_with_history_shows_count: Confirm dialog mentions execution log count """ import json import pytest from playwright.sync_api import Page, expect 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 _clean_all(page: Page): page.evaluate("""async () => { const logs = await window.__repos.executionLogs.getAll() for (const l of logs) await window.__repos.executionLogs.delete(l.id) const sessions = await window.__repos.sessions.getAll() for (const s of sessions) await window.__repos.sessions.delete(s.id) const exercises = await window.__repos.exercises.getAll() for (const e of exercises) await window.__repos.exercises.delete(e.id) const combos = await window.__repos.combos.getAll() for (const c of combos) await window.__repos.combos.delete(c.id) }""") def _create_exercise(page: Page, title: str) -> str: return page.evaluate( """async ([title]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.exercises.create({ title, description: '', asymmetric: false, alternate: false, image_urls: '[]', video_urls: '[]', default_reps: 5, default_weight: 0, created_by: userId }) }""", [title], ) def _create_session(page: Page, title: str) -> str: return page.evaluate( """async ([title]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.sessions.create({title, description: '', created_by: userId}) }""", [title], ) # ── R10 tests ───────────────────────────────────────────────────────────────── def test_underscore_matches_only_underscore(page: Page, app_url: str): """An exercise titled 'Push_up' IS found by searching 'Push_' (literal underscore).""" _wait_for_db(page, app_url) _clean_all(page) _create_exercise(page, "Push_up") _create_exercise(page, "Pushup") results_underscore = page.evaluate( """async () => window.__repos.exercises.search('Push_')""" ) results_any = page.evaluate( """async () => window.__repos.exercises.search('PushXup')""" ) titles_underscore = [e["title"] for e in results_underscore] assert "Push_up" in titles_underscore, f"'Push_' should find 'Push_up', got: {titles_underscore}" assert "Pushup" not in titles_underscore, f"'Push_' should NOT match 'Pushup', got: {titles_underscore}" assert len(results_any) == 0, f"'PushXup' should match nothing (was: {results_any})" def test_percent_does_not_match_all(page: Page, app_url: str): """Searching '100%' does NOT match '100kg squat' (% is literal, not wildcard).""" _wait_for_db(page, app_url) _clean_all(page) _create_exercise(page, "100kg squat") results = page.evaluate( """async () => window.__repos.exercises.search('100%')""" ) titles = [e["title"] for e in results] assert "100kg squat" not in titles, ( f"'100%' search should NOT match '100kg squat', got: {titles}" ) def test_normal_search_still_works(page: Page, app_url: str): """Regular substring search still returns matching results after escaping fix.""" _wait_for_db(page, app_url) _clean_all(page) _create_exercise(page, "Deadlift") _create_exercise(page, "Romanian Deadlift") _create_exercise(page, "Squat") results = page.evaluate( """async () => window.__repos.exercises.search('Deadlift')""" ) titles = [e["title"] for e in results] assert "Deadlift" in titles, f"'Deadlift' search should find 'Deadlift'" assert "Romanian Deadlift" in titles, f"'Deadlift' search should find 'Romanian Deadlift'" assert "Squat" not in titles, f"'Deadlift' search should not find 'Squat'" def test_percent_sign_in_title_is_found(page: Page, app_url: str): """An exercise with '%' in the title is found by searching for '%'.""" _wait_for_db(page, app_url) _clean_all(page) _create_exercise(page, "100% Effort") _create_exercise(page, "Normal Exercise") results = page.evaluate( """async () => window.__repos.exercises.search('100%')""" ) titles = [e["title"] for e in results] assert "100% Effort" in titles, f"'100%' should find '100% Effort', got: {titles}" assert "Normal Exercise" not in titles, f"Normal exercise should not appear in '%' search" # ── R12 tests ───────────────────────────────────────────────────────────────── def test_unknown_hash_redirects_to_home(page: Page, app_url: str): """Navigating to an unknown hash path redirects to the Home view.""" _wait_for_db(page, app_url) page.evaluate("() => { window.location.hash = '#/this-does-not-exist/at-all' }") page.wait_for_timeout(500) # Should be redirected to home home_card = page.locator('[data-testid="home-stats"], [data-testid="home-recent"], h1, .home-view') # Check the URL hash is now /home page.wait_for_function( "window.location.hash === '#/home' || window.location.hash === '#/'", timeout=3000, ) # ── R14 tests ───────────────────────────────────────────────────────────────── def _trigger_import_with_json(page: Page, json_str: str, test_id: str = "settings-import"): """Trigger the import dialog via the settings page import button.""" with page.expect_file_chooser() as fc_info: page.locator(f'[data-testid="{test_id}"]').click() file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) def test_different_user_import_skips_settings(page: Page, app_url: str): """A different-user import does not overwrite local user_name, language, or theme.""" _wait_for_db(page, app_url) _clean_all(page) page.evaluate("() => { window.location.hash = '#/exercises' }") page.wait_for_timeout(300) original_name = page.evaluate( """async () => await window.__repos.settings.get('user_name')""" ) # Build envelope with a different user UUID, including settings and one exercise different_user_json = page.evaluate( """async () => { const differentUuid = 'different-user-00000000-0000-0000-0000-000000000000' return JSON.stringify({ metadata: { appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1, exportDate: new Date().toISOString(), exportedBy: { name: 'Other Person', uuid: differentUuid }, }, data: { settings: { user_name: 'Invader Name', language: 'da', theme: 'dark' }, exercises: [{ id: 'foreign-ex-id', title: 'Foreign Exercise', description: '', asymmetric: 0, alternate: 0, image_urls: '[]', video_urls: '[]', default_reps: 5, default_weight: 0, created_by: differentUuid, }] }, }) }""" ) _trigger_import_with_json(page, different_user_json, "exercise-import-btn") # Suffix prompt appears for different user — wait for it, fill it, confirm page.locator('[data-testid="import-suffix-input"]').wait_for(state="visible", timeout=5000) page.locator('[data-testid="import-suffix-input"]').fill("OT") page.locator('[data-testid="import-suffix-confirm"]').click() # Wait for preview and apply expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) actual_name = page.evaluate( """async () => await window.__repos.settings.get('user_name')""" ) actual_lang = page.evaluate( """async () => await window.__repos.settings.get('language')""" ) assert actual_name == original_name, ( f"user_name should not change on cross-user import: was {original_name!r}, got {actual_name!r}" ) assert actual_lang != "da", ( f"language should not switch to 'da' on cross-user import, got: {actual_lang!r}" ) def test_same_user_import_applies_settings(page: Page, app_url: str): """A same-user import does apply settings (language, theme).""" _wait_for_db(page, app_url) page.evaluate("() => { window.location.hash = '#/exercises' }") page.wait_for_timeout(300) original_lang = page.evaluate( """async () => await window.__repos.settings.get('language')""" ) same_user_json = page.evaluate( """async () => { const localUuid = await window.__repos.settings.get('user_uuid') return JSON.stringify({ metadata: { appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1, exportDate: new Date().toISOString(), exportedBy: { name: 'Me', uuid: localUuid }, }, data: { settings: { language: 'fr', theme: 'dark' }, exercises: [] }, }) }""" ) _trigger_import_with_json(page, same_user_json, "exercise-import-btn") expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) actual_lang = page.evaluate( """async () => await window.__repos.settings.get('language')""" ) # Restore original language page.evaluate( """async ([lang]) => { await window.__repos.settings.set('language', lang) await window.__repos.settings.set('theme', 'light') }""", [original_lang or "en"], ) assert actual_lang == "fr", f"Same-user import should apply language='fr', got: {actual_lang!r}" # ── R15 tests ───────────────────────────────────────────────────────────────── def _seed_execution_log(page: Page, sess_id: str, ex_id: str, count: int = 1): """Seed `count` execution log items referencing the given exercise.""" page.evaluate( """async ([sessId, exId, count]) => { const now = new Date().toISOString() for (let i = 0; i < count; i++) { const logId = await window.__repos.executionLogs.create({ session_id: sessId, started_at: now, finished_at: now, }) await window.__repos.executionLogs.addItem({ log_id: logId, exercise_id: exId, reps_done: 5, weight_used: 0, side: null, completed: true, timestamp: now, }) } }""", [sess_id, ex_id, count], ) def test_delete_exercise_with_history_shows_count(page: Page, app_url: str): """Deleting an exercise with history shows the journal entry count in the confirm dialog.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Tracked Exercise") sess_id = _create_session(page, "Logged Session") _seed_execution_log(page, sess_id, ex_id, count=3) # Navigate to the exercise detail page page.evaluate( """([exId]) => { window.location.hash = '#/exercises/' + exId }""", [ex_id], ) page.wait_for_selector('[data-testid="exercise-delete-btn"]', timeout=5000) # Intercept the confirm dialog to read its message dialog_messages = [] page.once("dialog", lambda d: (dialog_messages.append(d.message), d.dismiss())) page.locator('[data-testid="exercise-delete-btn"]').click() page.wait_for_timeout(300) assert len(dialog_messages) == 1, "Delete should trigger a confirm dialog" msg = dialog_messages[0] assert "3" in msg, f"Confirm dialog should mention 3 journal entries, got: {msg!r}" def test_delete_session_with_history_shows_count(page: Page, app_url: str): """Deleting a session with logs shows the execution log count in the confirm dialog.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Session Exercise") sess_id = _create_session(page, "Logged Session 2") # Set an exercise item on the session so we can seed a log page.evaluate( """async ([sessId, exId]) => window.__repos.sessions.setItems(sessId, [{ item_id: exId, item_type: 'exercise', position: 0, repetitions: 5, weight: 0 }])""", [sess_id, ex_id], ) _seed_execution_log(page, sess_id, ex_id, count=2) # Navigate to session detail page.evaluate( """([sessId]) => { window.location.hash = '#/sessions/' + sessId }""", [sess_id], ) page.wait_for_selector('[data-testid="session-delete-btn"]', timeout=5000) dialog_messages = [] page.once("dialog", lambda d: (dialog_messages.append(d.message), d.dismiss())) page.locator('[data-testid="session-delete-btn"]').click() page.wait_for_timeout(300) assert len(dialog_messages) == 1, "Delete should trigger a confirm dialog" msg = dialog_messages[0] assert "2" in msg, f"Confirm dialog should mention 2 execution logs, got: {msg!r}"