394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""
|
|
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}"
|