373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""
|
|
Step 1 — Database Layer Tests
|
|
- test_db_initializes: Database initializes without errors
|
|
- test_uuid_generated: A UUID is generated and stored in settings
|
|
- test_app_version_stored: App version is stored in settings
|
|
- test_default_settings: Default settings (user_name, language, theme) are created
|
|
- test_exercise_crud: Full CRUD cycle on exercises
|
|
- test_exercise_search: Search exercises by title
|
|
- test_data_survives_reload: Data persists across page reload
|
|
- test_settings_crud: Settings can be read and written
|
|
- test_schema_tables_exist: All expected tables exist in the schema
|
|
- test_foreign_keys_enabled: Foreign key constraints are enforced- test_combo_none_type: Combo can be created with type NONE
|
|
- test_session_item_repetitions: Session items support repetitions field
|
|
- test_collection_crud: Full CRUD cycle on collections (renamed from groups)
|
|
- test_execution_log_recent_and_count: getRecent and getCountSince work correctly"""
|
|
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 test_db_initializes(page: Page, app_url: str):
|
|
"""Database initializes and sets the data-db-ready attribute."""
|
|
page.goto(app_url)
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'"
|
|
" || document.documentElement.getAttribute('data-db-error') !== null",
|
|
timeout=15000,
|
|
)
|
|
error = page.evaluate("document.documentElement.getAttribute('data-db-error')")
|
|
assert error is None, f"DB initialization failed: {error}"
|
|
|
|
|
|
def test_uuid_generated(page: Page, app_url: str):
|
|
"""A UUID is generated on first launch and stored in settings."""
|
|
_wait_for_db(page, app_url)
|
|
uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
|
|
assert uuid is not None, "No user_uuid found in settings"
|
|
# UUID v4 format: 8-4-4-4-12 hex chars
|
|
import re
|
|
assert re.match(
|
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
uuid
|
|
), f"user_uuid is not a valid UUID: {uuid}"
|
|
|
|
|
|
def test_app_version_stored(page: Page, app_url: str):
|
|
"""App version from package.json is stored in settings."""
|
|
_wait_for_db(page, app_url)
|
|
version = page.evaluate("async () => await window.__repos.settings.get('app_version')")
|
|
assert version is not None, "No app_version found in settings"
|
|
assert version == "1.0.0", f"Unexpected app_version: {version}"
|
|
|
|
|
|
def test_default_settings(page: Page, app_url: str):
|
|
"""Default settings are created on first launch."""
|
|
_wait_for_db(page, app_url)
|
|
settings = page.evaluate("async () => await window.__repos.settings.getAll()")
|
|
assert settings["user_name"] == "User", f"Unexpected user_name: {settings.get('user_name')}"
|
|
assert settings["language"] == "en", f"Unexpected language: {settings.get('language')}"
|
|
assert settings["theme"] == "light", f"Unexpected theme: {settings.get('theme')}"
|
|
assert settings["db_created_at"] is not None, "db_created_at should be set on first launch"
|
|
|
|
|
|
def test_db_created_at_is_valid_iso_timestamp(page: Page, app_url: str):
|
|
"""db_created_at is stored as a valid ISO-8601 timestamp on first launch."""
|
|
_wait_for_db(page, app_url)
|
|
created_at = page.evaluate("async () => await window.__repos.settings.get('db_created_at')")
|
|
assert created_at is not None, "db_created_at should be set"
|
|
parsed = page.evaluate(f"new Date('{created_at}').getTime()")
|
|
assert parsed > 0, f"db_created_at is not a valid date: {created_at}"
|
|
|
|
|
|
def test_exercise_crud(page: Page, app_url: str):
|
|
"""Full CRUD cycle: create, read, update, delete an exercise."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create
|
|
ex_id = page.evaluate("""
|
|
window.__repos.exercises.create({
|
|
title: 'Push-ups',
|
|
description: 'Basic push-up exercise',
|
|
asymmetric: false,
|
|
alternate: false,
|
|
default_reps: 10,
|
|
default_weight: 0,
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
assert ex_id is not None, "Exercise creation returned no ID"
|
|
|
|
# Read
|
|
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
|
|
assert exercise is not None, "Exercise not found after creation"
|
|
assert exercise["title"] == "Push-ups"
|
|
assert exercise["description"] == "Basic push-up exercise"
|
|
assert exercise["asymmetric"] is False
|
|
assert exercise["alternate"] is False
|
|
assert exercise["default_reps"] == 10
|
|
|
|
# Update
|
|
page.evaluate(f"""
|
|
window.__repos.exercises.update('{ex_id}', {{
|
|
title: 'Diamond Push-ups',
|
|
description: 'Advanced push-up variation',
|
|
asymmetric: true,
|
|
alternate: true,
|
|
default_reps: 8,
|
|
default_weight: 0
|
|
}})
|
|
""")
|
|
updated = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
|
|
assert updated["title"] == "Diamond Push-ups"
|
|
assert updated["default_reps"] == 8
|
|
assert updated["asymmetric"] is True
|
|
assert updated["alternate"] is True
|
|
|
|
# Delete
|
|
page.evaluate(f"window.__repos.exercises.delete('{ex_id}')")
|
|
deleted = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
|
|
assert deleted is None, "Exercise still exists after deletion"
|
|
|
|
|
|
def test_exercise_search(page: Page, app_url: str):
|
|
"""Search exercises by title."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create a few exercises
|
|
page.evaluate("""
|
|
(async () => {
|
|
await window.__repos.exercises.create({ title: 'Squat', description: '', created_by: 'test' });
|
|
await window.__repos.exercises.create({ title: 'Bench Press', description: '', created_by: 'test' });
|
|
await window.__repos.exercises.create({ title: 'Squat Jump', description: '', created_by: 'test' });
|
|
})()
|
|
""")
|
|
|
|
results = page.evaluate("window.__repos.exercises.search('Squat')")
|
|
assert len(results) >= 2, f"Expected at least 2 results for 'Squat', got {len(results)}"
|
|
titles = [r["title"] for r in results]
|
|
assert "Squat" in titles
|
|
assert "Squat Jump" in titles
|
|
|
|
# Cleanup
|
|
page.evaluate("""
|
|
(async () => {
|
|
const all = await window.__repos.exercises.getAll();
|
|
for (const e of all) await window.__repos.exercises.delete(e.id);
|
|
})()
|
|
""")
|
|
|
|
|
|
def test_data_survives_reload(page: Page, app_url: str):
|
|
"""Data persists across page reload (OPFS persistence)."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create an exercise
|
|
ex_id = page.evaluate("""
|
|
window.__repos.exercises.create({
|
|
title: 'Persistent Exercise',
|
|
description: 'Should survive reload',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
|
|
# Reload the page
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
|
|
# Verify exercise still exists
|
|
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
|
|
assert exercise is not None, "Exercise lost after reload — persistence may not be working"
|
|
assert exercise["title"] == "Persistent Exercise"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.exercises.delete('{ex_id}')")
|
|
|
|
|
|
def test_settings_crud(page: Page, app_url: str):
|
|
"""Settings can be read and updated."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Read existing
|
|
original = page.evaluate("async () => await window.__repos.settings.get('user_name')")
|
|
assert original == "User"
|
|
|
|
# Update
|
|
page.evaluate("async () => { await window.__repos.settings.set('user_name', 'TestUser') }")
|
|
updated = page.evaluate("async () => await window.__repos.settings.get('user_name')")
|
|
assert updated == "TestUser", f"Expected 'TestUser', got '{updated}'"
|
|
|
|
# Restore
|
|
page.evaluate("async () => { await window.__repos.settings.set('user_name', 'User') }")
|
|
|
|
|
|
def test_schema_tables_exist(page: Page, app_url: str):
|
|
"""All expected tables exist in the database."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
tables = page.evaluate("""
|
|
window.__db.selectAll(
|
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
)
|
|
""")
|
|
table_names = [t["name"] for t in tables]
|
|
expected = [
|
|
"collection", "collection_session", "combo", "combo_exercise",
|
|
"execution_log", "execution_log_item", "exercise",
|
|
"schema_version", "session", "session_item", "settings", "suffix"
|
|
]
|
|
for name in expected:
|
|
assert name in table_names, f"Table '{name}' not found. Found: {table_names}"
|
|
|
|
|
|
def test_foreign_keys_enabled(page: Page, app_url: str):
|
|
"""Foreign key constraints are enforced."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Try to insert a session_item referencing a non-existent session
|
|
error = page.evaluate("""
|
|
(async () => {
|
|
try {
|
|
await window.__db.run(
|
|
"INSERT INTO session_item (session_id, item_id, item_type, position) VALUES (?, ?, ?, ?)",
|
|
['nonexistent', 'item1', 'exercise', 0]
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
""")
|
|
assert error is not None, "Foreign key constraint was not enforced"
|
|
|
|
|
|
def test_combo_none_type(page: Page, app_url: str):
|
|
"""Combo can be created with type NONE."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
combo_id = page.evaluate("""
|
|
window.__repos.combos.create({
|
|
title: 'Simple Combo',
|
|
description: 'A combo with no special type',
|
|
type: 'NONE',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
assert combo_id is not None, "Combo creation returned no ID"
|
|
|
|
combo = page.evaluate(f"window.__repos.combos.getById('{combo_id}')")
|
|
assert combo is not None, "Combo not found after creation"
|
|
assert combo["type"] == "NONE"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.combos.delete('{combo_id}')")
|
|
|
|
|
|
def test_session_item_repetitions(page: Page, app_url: str):
|
|
"""Session items support a repetitions (series) field."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create a session and an exercise
|
|
session_id = page.evaluate("""
|
|
window.__repos.sessions.create({
|
|
title: 'Repetitions Test Session',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
ex_id = page.evaluate("""
|
|
window.__repos.exercises.create({
|
|
title: 'Burpees',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
|
|
# Set items with repetitions
|
|
page.evaluate(f"""
|
|
window.__repos.sessions.setItems('{session_id}', [
|
|
{{ item_id: '{ex_id}', item_type: 'exercise', repetitions: 3 }}
|
|
])
|
|
""")
|
|
|
|
session = page.evaluate(f"window.__repos.sessions.getById('{session_id}')")
|
|
assert session["items"][0]["repetitions"] == 3, f"Expected repetitions=3, got {session['items'][0].get('repetitions')}"
|
|
|
|
# Default repetitions should be 1
|
|
page.evaluate(f"""
|
|
window.__repos.sessions.setItems('{session_id}', [
|
|
{{ item_id: '{ex_id}', item_type: 'exercise' }}
|
|
])
|
|
""")
|
|
session = page.evaluate(f"window.__repos.sessions.getById('{session_id}')")
|
|
assert session["items"][0]["repetitions"] == 1, "Default repetitions should be 1"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.sessions.delete('{session_id}')")
|
|
page.evaluate(f"window.__repos.exercises.delete('{ex_id}')")
|
|
|
|
|
|
def test_collection_crud(page: Page, app_url: str):
|
|
"""Full CRUD cycle on collections (renamed from groups)."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create
|
|
coll_id = page.evaluate("""
|
|
window.__repos.collections.create({
|
|
label: 'Morning Routine',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
assert coll_id is not None, "Collection creation returned no ID"
|
|
|
|
# Read
|
|
coll = page.evaluate(f"window.__repos.collections.getById('{coll_id}')")
|
|
assert coll is not None, "Collection not found after creation"
|
|
assert coll["label"] == "Morning Routine"
|
|
|
|
# Update
|
|
page.evaluate(f"""
|
|
window.__repos.collections.update('{coll_id}', {{ label: 'Evening Routine' }})
|
|
""")
|
|
updated = page.evaluate(f"window.__repos.collections.getById('{coll_id}')")
|
|
assert updated["label"] == "Evening Routine"
|
|
|
|
# Delete
|
|
page.evaluate(f"window.__repos.collections.delete('{coll_id}')")
|
|
deleted = page.evaluate(f"window.__repos.collections.getById('{coll_id}')")
|
|
assert deleted is None, "Collection still exists after deletion"
|
|
|
|
|
|
def test_execution_log_recent_and_count(page: Page, app_url: str):
|
|
"""getRecent returns last N logs with session title; getCountSince counts logs after a date."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create a session
|
|
session_id = page.evaluate("""
|
|
window.__repos.sessions.create({
|
|
title: 'Stats Test Session',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
""")
|
|
|
|
# Create execution logs with different dates
|
|
page.evaluate(f"""
|
|
(async () => {{
|
|
await window.__repos.executionLogs.create({{ session_id: '{session_id}', started_at: '2026-03-10T10:00:00Z', finished_at: '2026-03-10T11:00:00Z' }});
|
|
await window.__repos.executionLogs.create({{ session_id: '{session_id}', started_at: '2026-03-12T10:00:00Z', finished_at: '2026-03-12T11:00:00Z' }});
|
|
await window.__repos.executionLogs.create({{ session_id: '{session_id}', started_at: '2026-03-14T10:00:00Z', finished_at: '2026-03-14T11:00:00Z' }});
|
|
}})()
|
|
""")
|
|
|
|
# getRecent
|
|
recent = page.evaluate("window.__repos.executionLogs.getRecent(2)")
|
|
assert len(recent) == 2, f"Expected 2 recent logs, got {len(recent)}"
|
|
assert recent[0]["session_title"] == "Stats Test Session"
|
|
# Most recent first
|
|
assert recent[0]["started_at"] > recent[1]["started_at"]
|
|
|
|
# getCountSince
|
|
count = page.evaluate("window.__repos.executionLogs.getCountSince('2026-03-11T00:00:00Z')")
|
|
assert count == 2, f"Expected 2 logs since March 11, got {count}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.sessions.delete('{session_id}')")
|