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

1090 lines
46 KiB
Python

"""
Step 7 — Full Data Import/Export Tests
- test_full_export_import_roundtrip: Export all data then import back, verify all entities
- test_cross_entity_reference_integrity: After ID collision remapping, references stay valid
- test_new_id_propagation_to_combo: new_id UUID is propagated to combo exercise FK on collision
- test_per_entity_export_from_detail_views: Export single combo/session/collection from detail
- test_different_user_suffix_applies_to_all_types: Suffix applied to exercises, combos, sessions, collections
- test_same_user_collision_combo: Same-user combo collision with replace/skip
- test_same_user_collision_session: Same-user session collision with replace/skip
- test_same_user_collision_collection: Same-user collection collision with replace/skip
- test_export_filter_all_enabled: All checkboxes enabled in export filter dialog
- test_import_combo_list_view: Import button visible on combo list
- test_import_session_list_view: Import button visible on session list
- test_import_collection_list_view: Import button visible on collection list
- test_full_export_from_settings: Full export button in settings works
- test_import_as_mine_combo: Import-as-mine button works on combo list
- test_import_as_mine_session: Import-as-mine button works on session list
- test_execution_logs_same_user_roundtrip: Execution logs survive a same-user export/import cycle
- test_execution_log_id_remapping: Log item exercise_id is remapped when the exercise gets a new_id
- test_session_with_combo_item_remapping: Session combo item_id follows remap when combo gets new_id
- test_collection_session_remapping: Collection session ref follows remap when session gets new_id
- test_settings_import_roundtrip: Settings values are applied after import
- test_single_entity_export_buttons_session_collection: Export button visible on session/collection detail
- test_export_respects_active_search_filter: Exporting while a search filter is active only exports matching items
- test_single_export_bundles_dependencies: Exporting a combo/session/collection bundles its dependencies
- test_settings_export_filter_bundles_dependencies: Settings export filter bundles dependencies for a partial selection
"""
import json
import os
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 _go_to(page: Page, app_url: str, hash_path: str):
_wait_for_db(page, app_url)
page.click(f'a[href="{hash_path}"]')
page.wait_for_timeout(500)
def _clean_all(page: Page):
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
const combos = await window.__repos.combos.getAll();
for (const c of combos) await window.__repos.combos.delete(c.id);
const sessions = await window.__repos.sessions.getAll();
for (const s of sessions) await window.__repos.sessions.delete(s.id);
const collections = await window.__repos.collections.getAll();
for (const c of collections) await window.__repos.collections.delete(c.id);
const suffixes = await window.__repos.suffixes.getAll();
for (const s of suffixes) await window.__repos.suffixes.delete(s.user_uuid);
}"""
)
def _create_exercise(page: Page, title, exercise_id=None, created_by=None):
return page.evaluate(
"""async ([title, exerciseId, createdBy]) => {
const uuid = await window.__repos.settings.get('user_uuid');
const cb = createdBy || uuid;
if (exerciseId) {
await window.__repos.exercises.createWithId(exerciseId, {
title, description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 10, default_weight: 20,
created_by: cb,
});
return exerciseId;
}
return await window.__repos.exercises.create({
title, description: '', asymmetric: false, alternate: false,
image_urls: [], video_urls: [], default_reps: 10, default_weight: 20,
created_by: cb,
});
}""",
[title, exercise_id, created_by],
)
def _create_combo(page: Page, title, exercise_ids, combo_id=None, created_by=None):
return page.evaluate(
"""async ([title, exerciseIds, comboId, createdBy]) => {
const uuid = await window.__repos.settings.get('user_uuid');
const cb = createdBy || uuid;
const id = comboId || crypto.randomUUID();
await window.__repos.combos.createWithId(id, {
title, description: '', type: 'NONE', timer_config: {}, created_by: cb,
});
const exercises = exerciseIds.map((eid, i) => ({
exerciseId: eid, position: i, defaultReps: 5, defaultWeight: 10,
}));
await window.__repos.combos.setExercises(id, exercises);
return id;
}""",
[title, exercise_ids, combo_id, created_by],
)
def _create_session(page: Page, title, items, session_id=None, created_by=None):
return page.evaluate(
"""async ([title, items, sessionId, createdBy]) => {
const uuid = await window.__repos.settings.get('user_uuid');
const cb = createdBy || uuid;
const id = sessionId || crypto.randomUUID();
await window.__repos.sessions.createWithId(id, {
title, description: '', created_by: cb,
});
const sessionItems = items.map((item, i) => ({
item_id: item.item_id,
item_type: item.item_type,
repetitions: item.repetitions || 1,
weight: item.weight !== undefined ? item.weight : 0.0,
}));
await window.__repos.sessions.setItems(id, sessionItems);
return id;
}""",
[title, items, session_id, created_by],
)
def _create_collection(page: Page, label, session_ids, collection_id=None, created_by=None):
return page.evaluate(
"""async ([label, sessionIds, collectionId, createdBy]) => {
const uuid = await window.__repos.settings.get('user_uuid');
const cb = createdBy || uuid;
const id = collectionId || crypto.randomUUID();
await window.__repos.collections.createWithId(id, { label, created_by: cb });
await window.__repos.collections.setSessions(id, sessionIds);
return id;
}""",
[label, session_ids, collection_id, created_by],
)
def _build_full_export_json(page: Page):
"""Build a full export JSON envelope from current database state."""
return page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
const combos = await window.__repos.combos.getAllWithExercises();
const sessions = await window.__repos.sessions.getAllWithItems();
const collections = await window.__repos.collections.getAllWithSessions();
const executionLogs = await window.__repos.executionLogs.getAllWithItems();
const userName = await window.__repos.settings.get('user_name');
const userUuid = 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: userName || 'User', uuid: userUuid },
},
data: {
exercises, combos, sessions, collections, executionLogs,
},
});
}"""
)
def _build_different_user_export_json(page: Page):
"""Build an export JSON with a different user UUID."""
return page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
const combos = await window.__repos.combos.getAllWithExercises();
const sessions = await window.__repos.sessions.getAllWithItems();
const collections = await window.__repos.collections.getAllWithSessions();
return JSON.stringify({
metadata: {
appName: 'trainus',
appVersion: '1.0.0',
schemaVersion: 1,
exportDate: new Date().toISOString(),
exportedBy: { name: 'Other User', uuid: 'other-user-uuid-789' },
},
data: {
exercises, combos, sessions, collections,
},
});
}"""
)
def _load_fixture(filename: str) -> str:
"""Read a static JSON fixture from tests/fixtures/."""
path = os.path.join(os.path.dirname(__file__), "fixtures", filename)
with open(path, "r", encoding="utf-8") as f:
return f.read()
def _create_execution_log(page: Page, session_id: str, exercise_id: str) -> str:
"""Create an execution log with one completed item; returns the log ID."""
return page.evaluate(
"""async ([sessionId, exerciseId]) => {
const logId = await window.__repos.executionLogs.create({
session_id: sessionId,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
});
await window.__repos.executionLogs.addItem({
log_id: logId,
exercise_id: exerciseId,
round_number: 1,
reps_done: 10,
weight_used: 50,
side: null,
completed: true,
timestamp: new Date().toISOString(),
});
return logId;
}""",
[session_id, exercise_id],
)
def _trigger_import_with_json(page: Page, json_str: str, test_id: str = "exercise-import-btn"):
"""Trigger import by intercepting file chooser."""
with page.expect_file_chooser() as fc_info:
page.click(f'[data-testid="{test_id}"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "test-import.json",
"mimeType": "application/json",
"buffer": json_str.encode("utf-8"),
}
]
)
def test_full_export_import_roundtrip(page: Page, app_url: str):
"""Export all data then import back, verify all entities are restored."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
exercise_id = _create_exercise(page, "Push-ups")
combo_id = _create_combo(page, "Upper Body", [exercise_id])
session_id = _create_session(
page, "Day 1", [{"item_id": exercise_id, "item_type": "exercise", "repetitions": 10, "weight": 0}]
)
_create_collection(page, "Week 1", [session_id])
json_str = _build_full_export_json(page)
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
const combos = await window.__repos.combos.getAll();
for (const c of combos) await window.__repos.combos.delete(c.id);
const sessions = await window.__repos.sessions.getAll();
for (const s of sessions) await window.__repos.sessions.delete(s.id);
const collections = await window.__repos.collections.getAll();
for (const c of collections) await window.__repos.collections.delete(c.id);
}"""
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()
_trigger_import_with_json(page, json_str)
# Wait for the import dialog to finish parsing and land on a known step.
# Under parallel load, 500ms fixed wait is unreliable — wait for the actual
# apply button to become visible instead (present in both 'preview' and
# 'collisions' steps).
expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)
page.locator('[data-testid="import-apply"]').click()
# Wait for the report step rather than using a fixed timeout.
expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
page.click('[data-testid="import-done"]')
page.wait_for_timeout(500)
exercise_count = page.evaluate("async () => (await window.__repos.exercises.getAll()).length")
combo_count = page.evaluate("async () => (await window.__repos.combos.getAll()).length")
session_count = page.evaluate("async () => (await window.__repos.sessions.getAll()).length")
collection_count = page.evaluate("async () => (await window.__repos.collections.getAll()).length")
assert exercise_count == 1, f"Expected 1 exercise, got {exercise_count}"
assert combo_count == 1, f"Expected 1 combo, got {combo_count}"
assert session_count == 1, f"Expected 1 session, got {session_count}"
assert collection_count == 1, f"Expected 1 collection, got {collection_count}"
def test_cross_entity_reference_integrity(page: Page, app_url: str):
"""After ID collision remapping, combo exercises still reference correctly."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
other_uuid = "other-user-uuid-123"
ex_id = _create_exercise(page, "Squats", created_by=other_uuid)
_create_combo(page, "Leg Day", [ex_id], created_by=other_uuid)
json_str = _build_different_user_export_json(page)
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
const combos = await window.__repos.combos.getAll();
for (const c of combos) await window.__repos.combos.delete(c.id);
}"""
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str)
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("OD")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
page.locator('[data-testid="import-apply"]').click()
page.wait_for_timeout(1000)
page.click('[data-testid="import-done"]')
page.wait_for_timeout(500)
combo = page.evaluate(
"""async () => {
const combos = await window.__repos.combos.getAll();
if (combos.length === 0) return null;
return await window.__repos.combos.getById(combos[0].id);
}"""
)
assert combo is not None, "Combo should exist after import"
assert len(combo.get("exercises", [])) == 1, "Combo should have 1 exercise"
assert combo["exercises"][0]["title"] == "Squats [OD]", f"Exercise title should have suffix: {combo['exercises'][0]['title']}"
def test_per_entity_export_from_detail_views(page: Page, app_url: str):
"""Export single combo/session/collection from their detail views."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_id = _create_exercise(page, "Pull-ups")
combo_id = _create_combo(page, "Back Blast", [ex_id])
page.click('a[href="#/combos"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="combo-card-{combo_id}"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="combo-export-single-btn"]')).to_be_visible()
def test_different_user_suffix_applies_to_all_types(page: Page, app_url: str):
"""Suffix is applied to exercises, combos, sessions, and collections from different user."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
other_uuid = "other-user-456"
ex_id = _create_exercise(page, "Burpees", created_by=other_uuid)
_create_combo(page, "HIIT", [ex_id], created_by=other_uuid)
_create_session(
page, "Cardio Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 15, "weight": 0}],
created_by=other_uuid,
)
_create_collection(page, "Cardio Week", [], created_by=other_uuid)
json_str = _build_different_user_export_json(page)
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
const combos = await window.__repos.combos.getAll();
for (const c of combos) await window.__repos.combos.delete(c.id);
const sessions = await window.__repos.sessions.getAll();
for (const s of sessions) await window.__repos.sessions.delete(s.id);
const collections = await window.__repos.collections.getAll();
for (const c of collections) await window.__repos.collections.delete(c.id);
}"""
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str)
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("HI")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
page.locator('[data-testid="import-apply"]').click()
page.wait_for_timeout(1000)
page.click('[data-testid="import-done"]')
page.wait_for_timeout(500)
exercise_title = page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
return exercises[0]?.title;
}"""
)
combo_title = page.evaluate(
"""async () => {
const combos = await window.__repos.combos.getAll();
return combos[0]?.title;
}"""
)
session_title = page.evaluate(
"""async () => {
const sessions = await window.__repos.sessions.getAll();
return sessions[0]?.title;
}"""
)
collection_label = page.evaluate(
"""async () => {
const collections = await window.__repos.collections.getAll();
return collections[0]?.label;
}"""
)
assert "[HI]" in exercise_title, f"Exercise title should have suffix: {exercise_title}"
assert "[HI]" in combo_title, f"Combo title should have suffix: {combo_title}"
assert "[HI]" in session_title, f"Session title should have suffix: {session_title}"
assert "[HI]" in collection_label, f"Collection label should have suffix: {collection_label}"
def test_same_user_collision_combo(page: Page, app_url: str):
"""Same-user combo collision shows replace/skip options."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
ex_id = _create_exercise(page, "Deadlifts")
_create_combo(page, "Power", [ex_id], created_by=local_uuid)
json_str = _build_full_export_json(page)
page.click('a[href="#/combos"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str, "combo-import-btn")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="collision-group-combos"]')).to_be_visible()
def test_same_user_collision_session(page: Page, app_url: str):
"""Same-user session collision shows replace/skip options."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
ex_id = _create_exercise(page, "Lunges")
_create_session(
page, "Leg Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 12, "weight": 0}],
created_by=local_uuid,
)
json_str = _build_full_export_json(page)
page.click('a[href="#/sessions"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str, "session-import-btn")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="collision-group-sessions"]')).to_be_visible()
def test_same_user_collision_collection(page: Page, app_url: str):
"""Same-user collection collision shows replace/skip options."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
ex_id = _create_exercise(page, "Plank")
session_id = _create_session(
page, "Core", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 3, "weight": 0}],
created_by=local_uuid,
)
_create_collection(page, "Core Week", [session_id], created_by=local_uuid)
json_str = _build_full_export_json(page)
page.click('a[href="#/collections"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str, "collection-import-btn")
page.wait_for_timeout(500)
expect(page.locator('[data-testid="collision-group-collections"]')).to_be_visible()
def test_export_filter_all_enabled(page: Page, app_url: str):
"""All checkboxes are enabled in the export filter dialog."""
_go_to(page, app_url, "#/settings")
_clean_all(page)
page.click('a[href="#/settings"]')
page.wait_for_timeout(500)
page.click('[data-testid="settings-export"]')
page.wait_for_timeout(500)
for test_id in [
"export-filter-exercises",
"export-filter-combos",
"export-filter-sessions",
"export-filter-collections",
"export-filter-execution-logs",
"export-filter-settings",
]:
checkbox = page.locator(f'[data-testid="{test_id}"] input')
expect(checkbox).to_be_enabled()
page.click('[data-testid="export-filter-cancel"]')
def test_import_combo_list_view(page: Page, app_url: str):
"""Import button is visible on combo list view."""
_go_to(page, app_url, "#/combos")
expect(page.locator('[data-testid="combo-import-btn"]')).to_be_visible()
expect(page.locator('[data-testid="combo-export-btn"]')).to_be_visible()
def test_import_session_list_view(page: Page, app_url: str):
"""Import button is visible on session list view."""
_go_to(page, app_url, "#/sessions")
expect(page.locator('[data-testid="session-import-btn"]')).to_be_visible()
expect(page.locator('[data-testid="session-export-btn"]')).to_be_visible()
def test_import_collection_list_view(page: Page, app_url: str):
"""Import button is visible on collection list view."""
_go_to(page, app_url, "#/collections")
expect(page.locator('[data-testid="collection-import-btn"]')).to_be_visible()
expect(page.locator('[data-testid="collection-export-btn"]')).to_be_visible()
def test_full_export_from_settings(page: Page, app_url: str):
"""Full export button in settings is visible."""
_go_to(page, app_url, "#/settings")
_clean_all(page)
_create_exercise(page, "Test Exercise")
page.click('a[href="#/settings"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="settings-export-all"]')).to_be_visible()
# Clean up so subsequent tests (chromium variants) start with an empty DB.
_clean_all(page)
def test_import_as_mine_combo(page: Page, app_url: str):
"""Import-as-mine button is visible on combo list."""
_go_to(page, app_url, "#/combos")
expect(page.locator('[data-testid="combo-import-as-mine-btn"]')).to_be_visible()
def test_import_as_mine_session(page: Page, app_url: str):
"""Import-as-mine button is visible on session list."""
_go_to(page, app_url, "#/sessions")
expect(page.locator('[data-testid="session-import-as-mine-btn"]')).to_be_visible()
def test_new_id_propagation_to_combo(page: Page, app_url: str):
"""When a different-user exercise collides with a local exercise (same ID, different created_by),
a new UUID is generated and the combo's exercise FK is remapped to that new UUID."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
# Create a local exercise with a known, fixed UUID owned by the local user.
# This will collide with the imported exercise below (same ID, different created_by).
conflicting_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
_create_exercise(page, "Local Exercise", exercise_id=conflicting_id)
other_uuid = "other-user-uuid-collision-999"
combo_id = "11111111-2222-3333-4444-555555555555"
import_json = _load_fixture("cross_user_id_collision.json")
_trigger_import_with_json(page, import_json)
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("OT")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
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)
result = page.evaluate(
"""async ([conflictingId, otherUuid]) => {
const allExercises = await window.__repos.exercises.getAll();
const localEx = allExercises.find(e => e.id === conflictingId);
const importedEx = allExercises.find(e => e.created_by === otherUuid);
const allCombos = await window.__repos.combos.getAllWithExercises();
const importedCombo = allCombos.find(c => c.created_by === otherUuid);
return {
localExExists: !!localEx,
importedExId: importedEx?.id ?? null,
comboExerciseRef: importedCombo?.exercises?.[0]?.exercise_id ?? null,
};
}""",
[conflicting_id, other_uuid],
)
assert result["localExExists"], "Local exercise must still exist with its original ID"
assert result["importedExId"] is not None, "Imported exercise must exist"
assert result["importedExId"] != conflicting_id, (
f"Imported exercise must have a new UUID, not the conflicting one ({conflicting_id})"
)
assert result["comboExerciseRef"] == result["importedExId"], (
f"Combo exercise FK ({result['comboExerciseRef']}) must point to the "
f"new exercise UUID ({result['importedExId']}), not the original conflicting ID"
)
# ---------------------------------------------------------------------------
# New coverage tests added to complete all import/export paths
# ---------------------------------------------------------------------------
FIXTURE_USER_UUID = "facade00-0000-4000-a000-000000000001"
FIXTURE_EX1_ID = "facade00-0000-4000-a000-e00000000001"
FIXTURE_COMBO_ID = "facade00-0000-4000-a000-c00000000001"
FIXTURE_SESSION_ID = "facade00-0000-4000-a000-500000000001"
FIXTURE_LOG_ID = "facade00-0000-4000-a000-100000000001"
def test_execution_logs_same_user_roundtrip(page: Page, app_url: str):
"""Execution log (with one item) survives a same-user export → clear → reimport cycle."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_id = _create_exercise(page, "Log Roundtrip Exercise")
session_id = _create_session(
page,
"Log Roundtrip Session",
[{"item_id": ex_id, "item_type": "exercise", "repetitions": 10, "weight": 0}],
)
_create_execution_log(page, session_id, ex_id)
json_str = _build_full_export_json(page)
# Delete everything — execution_log cascades from session delete
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
const sessions = await window.__repos.sessions.getAll();
for (const s of sessions) await window.__repos.sessions.delete(s.id);
}"""
)
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
_trigger_import_with_json(page, json_str)
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)
result = page.evaluate(
"""async () => {
const logs = await window.__repos.executionLogs.getAllWithItems();
if (logs.length === 0) return { count: 0 };
const log = logs[0];
const sessionExists = !!(await window.__repos.sessions.getById(log.session_id));
return { count: logs.length, itemCount: log.items.length, sessionExists };
}"""
)
assert result["count"] == 1, f"Expected 1 execution log after reimport, got {result['count']}"
assert result["itemCount"] == 1, f"Expected 1 log item, got {result['itemCount']}"
assert result["sessionExists"], "Log session_id must reference an existing session after reimport"
def test_execution_log_id_remapping(page: Page, app_url: str):
"""When an exercise gets new_id on collision, the execution log item's exercise_id is remapped."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
# Collide with fixture's exercise 1: same ID, owned by local user → triggers new_id
_create_exercise(page, "Local Colliding Exercise", exercise_id=FIXTURE_EX1_ID)
_trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("FX")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
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)
result = page.evaluate(
"""async ([fixedExId, exporterUuid, logId]) => {
const importedEx = (await window.__repos.exercises.getAll())
.find(e => e.created_by === exporterUuid && e.id !== fixedExId);
const log = await window.__repos.executionLogs.getById(logId);
return {
importedExId: importedEx?.id ?? null,
logItemExerciseId: log?.items?.[0]?.exercise_id ?? null,
};
}""",
[FIXTURE_EX1_ID, FIXTURE_USER_UUID, FIXTURE_LOG_ID],
)
assert result["importedExId"] is not None, "Imported exercise must exist with a new UUID"
assert result["importedExId"] != FIXTURE_EX1_ID, (
f"Imported exercise must have a new UUID, not the colliding one ({FIXTURE_EX1_ID})"
)
assert result["logItemExerciseId"] == result["importedExId"], (
f"Log item exercise_id ({result['logItemExerciseId']}) must be remapped "
f"to the new exercise UUID ({result['importedExId']})"
)
def test_session_with_combo_item_remapping(page: Page, app_url: str):
"""Session item with item_type='combo' has its item_id remapped when the combo gets new_id."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
# Collide with fixture's combo: same ID, owned by local user → triggers new_id for imported combo
page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
await window.__repos.combos.createWithId(
'facade00-0000-4000-a000-c00000000001',
{ title: 'Local Combo', description: '', type: 'NONE',
timer_config: {}, created_by: uuid }
);
}"""
)
_trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("FX")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
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)
result = page.evaluate(
"""async ([fixedComboId, exporterUuid, sessionId]) => {
const importedCombo = (await window.__repos.combos.getAll())
.find(c => c.created_by === exporterUuid);
const sess = await window.__repos.sessions.getById(sessionId);
const comboItem = sess?.items?.find(i => i.item_type === 'combo');
return {
importedComboId: importedCombo?.id ?? null,
sessionComboItemId: comboItem?.item_id ?? null,
};
}""",
[FIXTURE_COMBO_ID, FIXTURE_USER_UUID, FIXTURE_SESSION_ID],
)
assert result["importedComboId"] is not None, "Imported combo must exist with a new UUID"
assert result["importedComboId"] != FIXTURE_COMBO_ID, (
f"Imported combo must have a new UUID, not the colliding one ({FIXTURE_COMBO_ID})"
)
assert result["sessionComboItemId"] == result["importedComboId"], (
f"Session combo item_id ({result['sessionComboItemId']}) must be remapped "
f"to the new combo UUID ({result['importedComboId']})"
)
def test_collection_session_remapping(page: Page, app_url: str):
"""Collection session reference is remapped when the imported session gets new_id on collision."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
# Collide with fixture's session: same ID, owned by local user → triggers new_id for imported session
page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
await window.__repos.sessions.createWithId(
'facade00-0000-4000-a000-500000000001',
{ title: 'Local Session', description: '', created_by: uuid }
);
}"""
)
_trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
page.wait_for_timeout(500)
page.locator('[data-testid="import-suffix-input"]').fill("FX")
page.locator('[data-testid="import-suffix-confirm"]').click()
page.wait_for_timeout(500)
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)
result = page.evaluate(
"""async (exporterUuid) => {
const importedSession = (await window.__repos.sessions.getAll())
.find(s => s.created_by === exporterUuid);
const allCollections = await window.__repos.collections.getAll();
const importedColl = allCollections.find(c => c.created_by === exporterUuid);
const collDetail = importedColl
? await window.__repos.collections.getById(importedColl.id)
: null;
return {
importedSessionId: importedSession?.id ?? null,
collectionSessionRef: collDetail?.sessions?.[0]?.session_id ?? null,
};
}""",
FIXTURE_USER_UUID,
)
assert result["importedSessionId"] is not None, "Imported session must exist with a new UUID"
assert result["importedSessionId"] != FIXTURE_SESSION_ID, (
f"Imported session must have a new UUID, not the colliding one ({FIXTURE_SESSION_ID})"
)
assert result["collectionSessionRef"] == result["importedSessionId"], (
f"Collection session ref ({result['collectionSessionRef']}) must be remapped "
f"to the new session UUID ({result['importedSessionId']})"
)
def test_settings_import_roundtrip(page: Page, app_url: str):
"""Settings values from an import envelope are applied to the database."""
_go_to(page, app_url, "#/exercises")
original_lang = page.evaluate(
"async () => await window.__repos.settings.get('language')"
)
# Build a settings-only envelope using the local UUID to avoid the suffix prompt
json_str = page.evaluate(
"""async () => {
const uuid = 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 },
},
data: { settings: { language: 'fr', theme: 'dark' } },
});
}"""
)
_trigger_import_with_json(page, json_str)
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)
lang = page.evaluate("async () => await window.__repos.settings.get('language')")
theme = page.evaluate("async () => await window.__repos.settings.get('theme')")
# Restore original settings so subsequent tests are not affected
page.evaluate(
"""async ([lang]) => {
await window.__repos.settings.set('language', lang);
await window.__repos.settings.set('theme', 'light');
}""",
[original_lang or "en"],
)
assert lang == "fr", f"language should be 'fr' after import, got '{lang}'"
assert theme == "dark", f"theme should be 'dark' after import, got '{theme}'"
def test_single_entity_export_buttons_session_collection(page: Page, app_url: str):
"""Single-entity export button is visible on both session and collection detail views."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_id = _create_exercise(page, "Detail Export Exercise")
session_id = _create_session(
page,
"Detail Export Session",
[{"item_id": ex_id, "item_type": "exercise", "repetitions": 5, "weight": 0}],
)
collection_id = _create_collection(page, "Detail Export Collection", [session_id])
# Session detail
page.click('a[href="#/sessions"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="session-card-{session_id}"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="session-export-single-btn"]')).to_be_visible()
# Collection detail
page.click('a[href="#/collections"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="collection-card-{collection_id}"]')
page.wait_for_timeout(500)
expect(page.locator('[data-testid="collection-export-single-btn"]')).to_be_visible()
def _export_via_button(page: Page, export_btn_selector: str, tmp_dir):
"""Click an export button, confirm the save-file prompt, and return the parsed JSON."""
page.click(export_btn_selector)
page.wait_for_selector('[data-testid="save-file-input"]')
with page.expect_download() as download_info:
page.click('[data-testid="save-file-confirm"]')
out_path = str(tmp_dir / "export.json")
download_info.value.save_as(out_path)
with open(out_path, "r", encoding="utf-8") as f:
return json.load(f)
def test_export_respects_active_search_filter(page: Page, app_url: str, tmp_path_factory):
"""Exporting from a list view while a search filter is active only exports the filtered items."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_a = _create_exercise(page, "Squats")
ex_b = _create_exercise(page, "Burpees")
_create_combo(page, "Squat Combo", [ex_a])
_create_combo(page, "Burpee Combo", [ex_b])
session_a = _create_session(
page, "Squat Session", [{"item_id": ex_a, "item_type": "exercise"}]
)
_create_session(page, "Burpee Session", [{"item_id": ex_b, "item_type": "exercise"}])
_create_collection(page, "Squat Week", [session_a])
_create_collection(page, "Burpee Week", [])
tmp_dir = tmp_path_factory.mktemp("filtered_export")
page.click('a[href="#/exercises"]')
page.wait_for_timeout(300)
page.click('[data-testid="exercise-search-toggle"]')
page.fill('[data-testid="exercise-search-input"]', "Squat")
page.wait_for_timeout(400)
data = _export_via_button(page, '[data-testid="exercise-export-btn"]', tmp_dir)
titles = [e["title"] for e in data["data"]["exercises"]]
assert titles == ["Squats"], f"Expected only the filtered exercise, got: {titles}"
page.click('a[href="#/combos"]')
page.wait_for_timeout(300)
page.click('[data-testid="combo-search-toggle"]')
page.fill('[data-testid="combo-search-input"]', "Squat")
page.wait_for_timeout(400)
data = _export_via_button(page, '[data-testid="combo-export-btn"]', tmp_dir)
titles = [c["title"] for c in data["data"]["combos"]]
assert titles == ["Squat Combo"], f"Expected only the filtered combo, got: {titles}"
page.click('a[href="#/sessions"]')
page.wait_for_timeout(300)
page.click('[data-testid="session-search-toggle"]')
page.fill('[data-testid="session-search-input"]', "Squat")
page.wait_for_timeout(400)
data = _export_via_button(page, '[data-testid="session-export-btn"]', tmp_dir)
titles = [s["title"] for s in data["data"]["sessions"]]
assert titles == ["Squat Session"], f"Expected only the filtered session, got: {titles}"
page.click('a[href="#/collections"]')
page.wait_for_timeout(300)
page.click('[data-testid="collection-search-toggle"]')
page.fill('[data-testid="collection-search-input"]', "Squat")
page.wait_for_timeout(400)
data = _export_via_button(page, '[data-testid="collection-export-btn"]', tmp_dir)
labels = [c["label"] for c in data["data"]["collections"]]
assert labels == ["Squat Week"], f"Expected only the filtered collection, got: {labels}"
def test_single_export_bundles_dependencies(page: Page, app_url: str, tmp_path_factory):
"""Exporting a combo/session/collection bundles the exercises/combos/sessions it depends on."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_a = _create_exercise(page, "Squats")
ex_b = _create_exercise(page, "Lunges")
combo_id = _create_combo(page, "Leg Combo", [ex_a, ex_b])
session_id = _create_session(
page,
"Leg Day",
[
{"item_id": ex_a, "item_type": "exercise"},
{"item_id": combo_id, "item_type": "combo"},
],
)
collection_id = _create_collection(page, "Leg Week", [session_id])
tmp_dir = tmp_path_factory.mktemp("dependency_export")
page.click('a[href="#/combos"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="combo-card-{combo_id}"]')
page.wait_for_timeout(500)
data = _export_via_button(page, '[data-testid="combo-export-single-btn"]', tmp_dir)
exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
assert exercise_titles == {"Squats", "Lunges"}, (
f"Combo export should bundle its exercises, got: {exercise_titles}"
)
page.click('a[href="#/sessions"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="session-card-{session_id}"]')
page.wait_for_timeout(500)
data = _export_via_button(page, '[data-testid="session-export-single-btn"]', tmp_dir)
exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
combo_titles = {c["title"] for c in data["data"].get("combos", [])}
assert exercise_titles == {"Squats", "Lunges"}, (
f"Session export should bundle all referenced exercises, got: {exercise_titles}"
)
assert combo_titles == {"Leg Combo"}, (
f"Session export should bundle its referenced combo, got: {combo_titles}"
)
page.click('a[href="#/collections"]')
page.wait_for_timeout(500)
page.click(f'[data-testid="collection-card-{collection_id}"]')
page.wait_for_timeout(500)
data = _export_via_button(page, '[data-testid="collection-export-single-btn"]', tmp_dir)
session_titles = {s["title"] for s in data["data"].get("sessions", [])}
combo_titles = {c["title"] for c in data["data"].get("combos", [])}
exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
assert session_titles == {"Leg Day"}, (
f"Collection export should bundle its session, got: {session_titles}"
)
assert combo_titles == {"Leg Combo"}, (
f"Collection export should bundle the session's combo, got: {combo_titles}"
)
assert exercise_titles == {"Squats", "Lunges"}, (
f"Collection export should bundle all transitive exercises, got: {exercise_titles}"
)
def test_settings_export_filter_bundles_dependencies(page: Page, app_url: str, tmp_path_factory):
"""Settings export filter dialog bundles dependencies even when only sessions is selected."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_a = _create_exercise(page, "Squats")
combo_id = _create_combo(page, "Leg Combo", [ex_a])
_create_session(
page,
"Leg Day",
[{"item_id": combo_id, "item_type": "combo"}],
)
tmp_dir = tmp_path_factory.mktemp("settings_filter_export")
page.click('a[href="#/settings"]')
page.wait_for_timeout(500)
page.click('[data-testid="settings-export"]')
page.wait_for_selector('[data-testid="export-filter-sessions"]')
# Uncheck everything except sessions
for testid in ["export-filter-exercises", "export-filter-combos", "export-filter-collections"]:
page.locator(f'[data-testid="{testid}"] input[type="checkbox"]').uncheck()
page.click('[data-testid="export-filter-confirm"]')
page.wait_for_selector('[data-testid="save-file-input"]')
with page.expect_download() as download_info:
page.click('[data-testid="save-file-confirm"]')
out_path = str(tmp_dir / "export.json")
download_info.value.save_as(out_path)
with open(out_path, "r", encoding="utf-8") as f:
data = json.load(f)
exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
combo_titles = {c["title"] for c in data["data"].get("combos", [])}
assert combo_titles == {"Leg Combo"}, (
f"Sessions-only export should bundle the referenced combo, got: {combo_titles}"
)
assert exercise_titles == {"Squats"}, (
f"Sessions-only export should bundle the transitively referenced exercise, got: {exercise_titles}"
)