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

1255 lines
40 KiB
Python

"""
Step 3b — Exercise JSON Export/Import Tests
- test_export_button_visible: Export button is visible on exercise list
- test_export_single_button_visible: Export button is visible on exercise detail
- test_export_import_roundtrip: Export exercises then import them back (no collision)
- test_import_invalid_json: Import rejects non-JSON file
- test_import_wrong_app: Import rejects file from different app
- test_same_user_collision_skip: Same-user import with skip action
- test_same_user_collision_replace: Same-user import with replace action
- test_same_user_collision_batch_replace_all: Batch replace all collisions
- test_same_user_collision_batch_skip_all: Batch skip all collisions
- test_different_user_suffix_prompt: Different-user import shows suffix prompt
- test_different_user_import_with_suffix: Different-user import adds suffix to titles
- test_different_user_update_same_id: Same external user same ID treated as update
- test_settings_export_filter_dialog: Settings export opens filter dialog
- test_settings_import_button: Settings import button works
- test_suffix_management_visible: Suffix section visible after import from different user
- test_suffix_rename_cascade: Renaming suffix updates titles across exercises
- test_suffix_delete: Suffix can be deleted from settings
- test_import_as_mine_button_visible: Import-as-mine buttons visible on exercises and settings
- test_import_as_mine_no_suffix_no_collision: Foreign exercises imported as own without suffix
- test_import_as_mine_collision_replace: Import-as-mine with collision uses replace action
- test_import_as_mine_collision_skip: Import-as-mine with collision uses skip action
- test_import_as_mine_from_settings: Import-as-mine button works from settings page
- test_import_as_mine_dialog_title: Import-as-mine shows correct dialog title
"""
import json
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 _go_to_settings(page: Page, app_url: str):
"""Navigate to the settings page."""
_wait_for_db(page, app_url)
page.click('a[href="#/settings"]')
page.wait_for_timeout(500)
def _clean_all(page: Page):
"""Remove all exercises and suffixes via the repositories."""
page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
for (const ex of exercises) {
await window.__repos.exercises.delete(ex.id);
}
const suffixes = await window.__repos.suffixes.getAll();
for (const s of suffixes) {
await window.__repos.suffixes.delete(s.user_uuid);
}
}"""
)
def _create_exercise_directly(page: Page, title, created_by=None, exercise_id=None):
"""Create an exercise directly via repository for test setup."""
return page.evaluate(
"""async ([title, createdBy, exerciseId]) => {
const uuid = await window.__repos.settings.get('user_uuid');
const cb = createdBy || uuid;
if (exerciseId) {
await window.__repos.exercises.createWithId(exerciseId, {
title: title,
description: 'Test desc',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 20,
created_by: cb,
});
return exerciseId;
} else {
const id = await window.__repos.exercises.create({
title: title,
description: 'Test desc',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 20,
created_by: cb,
});
return id;
}
}""",
[title, created_by, exercise_id],
)
def _build_export_json(exercises, exporter_name="Test User", exporter_uuid=None):
"""Build a valid export JSON envelope for testing."""
envelope = {
"metadata": {
"appName": "trainus",
"appVersion": "1.0.0",
"schemaVersion": 1,
"exportDate": "2026-03-30T12:00:00.000Z",
"exportedBy": {
"name": exporter_name,
"uuid": exporter_uuid or "test-uuid-1234",
},
},
"data": {"exercises": exercises},
}
return json.dumps(envelope)
def _get_local_uuid(page: Page):
"""Get the local user UUID."""
return page.evaluate(
"async () => await window.__repos.settings.get('user_uuid')"
)
def _trigger_import_with_json(page: Page, json_str: str):
"""Trigger the import flow by injecting a file into the file picker.
This uses a file chooser event listener to intercept the file picker
and inject the JSON content.
"""
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="exercise-import-btn"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "test-import.json",
"mimeType": "application/json",
"buffer": json_str.encode("utf-8"),
}
]
)
def _trigger_settings_import_with_json(page: Page, json_str: str):
"""Trigger import from settings page."""
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="settings-import"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "test-import.json",
"mimeType": "application/json",
"buffer": json_str.encode("utf-8"),
}
]
)
def _trigger_import_as_mine_with_json(page: Page, json_str: str):
"""Trigger the 'import as mine' flow from the exercises action bar."""
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="exercise-import-as-mine-btn"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "test-import.json",
"mimeType": "application/json",
"buffer": json_str.encode("utf-8"),
}
]
)
def _trigger_settings_import_as_mine_with_json(page: Page, json_str: str):
"""Trigger 'import as mine' from settings page."""
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="settings-import-as-mine"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "test-import.json",
"mimeType": "application/json",
"buffer": json_str.encode("utf-8"),
}
]
)
# ============================================================
# Tests
# ============================================================
def test_export_button_visible(page: Page, app_url: str):
"""Export button is visible on the exercise list action bar."""
_go_to_exercises(page, app_url)
_clean_all(page)
expect(page.locator('[data-testid="exercise-export-btn"]')).to_be_visible()
expect(page.locator('[data-testid="exercise-import-btn"]')).to_be_visible()
def test_export_single_button_visible(page: Page, app_url: str):
"""Export button is visible on exercise detail view."""
_go_to_exercises(page, app_url)
_clean_all(page)
ex_id = _create_exercise_directly(page, "Export Test Exercise")
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(f'[data-testid="exercise-card-{ex_id}"]')
page.wait_for_timeout(500)
expect(
page.locator('[data-testid="exercise-export-single-btn"]')
).to_be_visible()
# Cleanup
_go_to_exercises(page, app_url)
_clean_all(page)
def test_export_import_roundtrip(page: Page, app_url: str):
"""Export exercises to JSON, clear DB, import them back, verify they exist."""
_go_to_exercises(page, app_url)
_clean_all(page)
# Create exercises
_create_exercise_directly(page, "Roundtrip Exercise A")
_create_exercise_directly(page, "Roundtrip Exercise B")
# Get the export data by building it from the repo directly
local_uuid = _get_local_uuid(page)
exercises = page.evaluate(
"async () => await window.__repos.exercises.getAll()"
)
# Clean exercises
_clean_all(page)
# Build import JSON with the local user UUID (same user)
json_str = _build_export_json(
exercises,
exporter_name="Local User",
exporter_uuid=local_uuid,
)
# Reload page so it picks up the clean state
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)
# Trigger import
_trigger_import_with_json(page, json_str)
# Should see preview step (no collisions)
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
expect(page.locator('[data-testid="import-preview-step"]')).to_be_visible()
# Click import
page.click('[data-testid="import-apply"]')
# Should see report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible()
# Close dialog
page.click('[data-testid="import-done"]')
# Verify exercises exist
page.wait_for_timeout(500)
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2)
# Cleanup
_clean_all(page)
def test_import_invalid_json(page: Page, app_url: str):
"""Import rejects a file with invalid JSON content."""
_go_to_exercises(page, app_url)
_clean_all(page)
# Trigger import with invalid JSON
page.on("dialog", lambda d: d.accept())
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="exercise-import-btn"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "bad.json",
"mimeType": "application/json",
"buffer": b"this is not json",
}
]
)
# An alert dialog should appear (handled by dialog listener)
page.wait_for_timeout(1000)
# The import dialog should NOT be visible
expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible()
# Cleanup
_clean_all(page)
def test_import_wrong_app(page: Page, app_url: str):
"""Import rejects a file from a different application."""
_go_to_exercises(page, app_url)
_clean_all(page)
bad_envelope = json.dumps(
{
"metadata": {
"appName": "other-app",
"appVersion": "1.0.0",
"schemaVersion": 1,
"exportDate": "2026-03-30T12:00:00.000Z",
"exportedBy": {"name": "Test", "uuid": "abc"},
},
"data": {"exercises": []},
}
)
page.on("dialog", lambda d: d.accept())
with page.expect_file_chooser() as fc_info:
page.click('[data-testid="exercise-import-btn"]')
file_chooser = fc_info.value
file_chooser.set_files(
[
{
"name": "wrong.json",
"mimeType": "application/json",
"buffer": bad_envelope.encode("utf-8"),
}
]
)
page.wait_for_timeout(1000)
expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible()
_clean_all(page)
def test_same_user_collision_skip(page: Page, app_url: str):
"""Same-user import with 'skip' action keeps the existing exercise."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
ex_id = _create_exercise_directly(page, "Collision Exercise")
# Build import with same ID but different description
exercises = [
{
"id": ex_id,
"title": "Collision Exercise",
"description": "Imported version",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 99,
"default_weight": 99,
"created_by": local_uuid,
}
]
json_str = _build_export_json(exercises, "Local User", local_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)
_trigger_import_with_json(page, json_str)
# Should see collisions step
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Default is skip, so just click apply
page.click('[data-testid="import-apply"]')
# Should see report with skipped
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify original exercise is unchanged (10 reps, not 99)
exercise_data = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{ex_id}')"""
)
assert exercise_data["default_reps"] == 10
_clean_all(page)
def test_same_user_collision_replace(page: Page, app_url: str):
"""Same-user import with 'replace' action overwrites the existing exercise."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
ex_id = _create_exercise_directly(page, "Replace Exercise")
exercises = [
{
"id": ex_id,
"title": "Replace Exercise",
"description": "Replaced version",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 99,
"default_weight": 99,
"created_by": local_uuid,
}
]
json_str = _build_export_json(exercises, "Local User", local_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)
_trigger_import_with_json(page, json_str)
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Click replace on the first collision (exercise-specific testid)
page.click('[data-testid="collision-replace-exercise-0"]')
page.click('[data-testid="import-apply"]')
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify exercise was replaced (99 reps now)
exercise_data = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{ex_id}')"""
)
assert exercise_data["default_reps"] == 99
_clean_all(page)
def test_same_user_collision_batch_replace_all(page: Page, app_url: str):
"""Batch 'Replace All' replaces all collisions at once."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
id_a = _create_exercise_directly(page, "Batch A")
id_b = _create_exercise_directly(page, "Batch B")
exercises = [
{
"id": id_a,
"title": "Batch A",
"description": "Replaced A",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 77,
"default_weight": 77,
"created_by": local_uuid,
},
{
"id": id_b,
"title": "Batch B",
"description": "Replaced B",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 88,
"default_weight": 88,
"created_by": local_uuid,
},
]
json_str = _build_export_json(exercises, "Local User", local_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)
_trigger_import_with_json(page, json_str)
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Click Replace All (exercise-specific testid)
page.click('[data-testid="exercise-replace-all"]')
page.click('[data-testid="import-apply"]')
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_contain_text("2")
page.click('[data-testid="import-done"]')
# Verify both replaced
ex_a = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{id_a}')"""
)
ex_b = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{id_b}')"""
)
assert ex_a["default_reps"] == 77
assert ex_b["default_reps"] == 88
_clean_all(page)
def test_same_user_collision_batch_skip_all(page: Page, app_url: str):
"""Batch 'Skip All' skips all collisions."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
id_a = _create_exercise_directly(page, "Skip A")
id_b = _create_exercise_directly(page, "Skip B")
exercises = [
{
"id": id_a,
"title": "Skip A",
"description": "Should not replace",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 99,
"default_weight": 99,
"created_by": local_uuid,
},
{
"id": id_b,
"title": "Skip B",
"description": "Should not replace",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 99,
"default_weight": 99,
"created_by": local_uuid,
},
]
json_str = _build_export_json(exercises, "Local User", local_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)
_trigger_import_with_json(page, json_str)
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Click Skip All (exercise-specific testid)
page.click('[data-testid="exercise-skip-all"]')
page.click('[data-testid="import-apply"]')
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-skipped"]')).to_contain_text("2")
page.click('[data-testid="import-done"]')
# Verify originals unchanged
ex_a = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{id_a}')"""
)
assert ex_a["default_reps"] == 10
_clean_all(page)
def test_different_user_suffix_prompt(page: Page, app_url: str):
"""Importing from a different user shows the suffix prompt."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "other-user-uuid-9999"
exercises = [
{
"id": "ext-exercise-id-1",
"title": "External Push-ups",
"description": "From another user",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 15,
"default_weight": 0,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Jane Doe", other_uuid)
_trigger_import_with_json(page, json_str)
# Should see suffix step
page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000)
expect(page.locator('[data-testid="import-suffix-input"]')).to_be_visible()
# Cancel
page.click('[data-testid="import-cancel"]')
_clean_all(page)
def test_different_user_import_with_suffix(page: Page, app_url: str):
"""Different-user import applies suffix to titles."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "other-user-uuid-8888"
exercises = [
{
"id": "ext-exercise-suffix-1",
"title": "Squats",
"description": "External exercise",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 20,
"default_weight": 50,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Bob Smith", other_uuid)
_trigger_import_with_json(page, json_str)
# Suffix step
page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000)
# Clear and type new suffix
page.fill('[data-testid="import-suffix-input"]', "BS")
page.click('[data-testid="import-suffix-confirm"]')
# Preview step
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
page.click('[data-testid="import-apply"]')
# Report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify title has suffix
page.wait_for_timeout(500)
titles = page.locator('[data-testid="exercise-card-title"]')
expect(titles).to_have_count(1)
expect(titles.first).to_contain_text("Squats [BS]")
_clean_all(page)
def test_different_user_update_same_id(page: Page, app_url: str):
"""Same external user, same ID is treated as an update (no new ID)."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "other-user-uuid-7777"
ext_id = "ext-update-id-1"
# First, create the exercise as if it was previously imported
_create_exercise_directly(page, "Lunges [JD]", other_uuid, ext_id)
# Also register the suffix
page.evaluate(
"""async ([uuid]) => {
await window.__repos.suffixes.create({
user_uuid: uuid,
user_name: 'John Doe',
suffix: 'JD',
});
}""",
[other_uuid],
)
# Import update with same external user + same ID
exercises = [
{
"id": ext_id,
"title": "Lunges",
"description": "Updated version",
"asymmetric": True,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 30,
"default_weight": 15,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "John Doe", other_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)
_trigger_import_with_json(page, json_str)
# Should skip suffix prompt (already registered) and go to preview
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
page.click('[data-testid="import-apply"]')
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-exercises-updated"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify exercise was updated (same ID, new data)
exercise = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{ext_id}')"""
)
assert exercise is not None
assert exercise["default_reps"] == 30
assert exercise["title"] == "Lunges [JD]"
_clean_all(page)
def test_settings_export_filter_dialog(page: Page, app_url: str):
"""Settings export button opens the filter dialog."""
_go_to_settings(page, app_url)
_clean_all(page)
page.click('[data-testid="settings-export"]')
page.wait_for_selector('[data-testid="modal-card"]', timeout=5000)
expect(page.locator('[data-testid="export-filter-exercises"]')).to_be_visible()
expect(page.locator('[data-testid="export-filter-combos"]')).to_be_visible()
expect(page.locator('[data-testid="export-filter-sessions"]')).to_be_visible()
expect(page.locator('[data-testid="export-filter-collections"]')).to_be_visible()
expect(page.locator('[data-testid="export-filter-execution-logs"]')).to_be_visible()
expect(page.locator('[data-testid="export-filter-settings"]')).to_be_visible()
# Close
page.click('[data-testid="export-filter-cancel"]')
page.wait_for_timeout(300)
expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible()
_clean_all(page)
def test_settings_import_button(page: Page, app_url: str):
"""Settings import button opens file picker and handles import."""
_go_to_settings(page, app_url)
_clean_all(page)
other_uuid = "settings-import-uuid-1111"
exercises = [
{
"id": "settings-import-ex-1",
"title": "Settings Import Exercise",
"description": "Via settings",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 5,
"default_weight": 10,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Settings User", other_uuid)
_trigger_settings_import_with_json(page, json_str)
# Should see suffix step (different user)
page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000)
# Enter suffix and continue
page.fill('[data-testid="import-suffix-input"]', "SU")
page.click('[data-testid="import-suffix-confirm"]')
# Preview
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
page.click('[data-testid="import-apply"]')
# Report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
page.click('[data-testid="import-done"]')
# Verify exercise created
exercise = page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
return exercises.find(e => e.title === 'Settings Import Exercise [SU]');
}"""
)
assert exercise is not None
_clean_all(page)
def test_suffix_management_visible(page: Page, app_url: str):
"""Suffix management section is visible after an import from a different user."""
_go_to_exercises(page, app_url)
_clean_all(page)
# Create a suffix entry directly
page.evaluate(
"""async () => {
await window.__repos.suffixes.create({
user_uuid: 'suffix-mgmt-uuid-1',
user_name: 'Alice',
suffix: 'AL',
});
}"""
)
_go_to_settings(page, app_url)
expect(page.locator('[data-testid="suffix-management"]')).to_be_visible()
expect(page.locator('[data-testid="suffix-value"]')).to_contain_text("[AL]")
_clean_all(page)
def test_suffix_rename_cascade(page: Page, app_url: str):
"""Renaming a suffix updates exercise titles that contain the old suffix."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "rename-test-uuid-5555"
# Create suffix and exercise
page.evaluate(
f"""async () => {{
await window.__repos.suffixes.create({{
user_uuid: '{other_uuid}',
user_name: 'Charlie',
suffix: 'CH',
}});
await window.__repos.exercises.createWithId('rename-ex-1', {{
title: 'Burpees [CH]',
description: 'Test',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 0,
created_by: '{other_uuid}',
}});
}}"""
)
_go_to_settings(page, app_url)
# Start editing
page.click('[data-testid="suffix-edit-btn"]')
page.fill('[data-testid="suffix-edit-input"]', "CW")
page.click('[data-testid="suffix-save-btn"]')
page.wait_for_timeout(500)
# Verify suffix updated in UI
expect(page.locator('[data-testid="suffix-value"]')).to_contain_text("[CW]")
# Verify exercise title was updated
exercise = page.evaluate(
"""async () => await window.__repos.exercises.getById('rename-ex-1')"""
)
assert exercise["title"] == "Burpees [CW]"
_clean_all(page)
def test_suffix_delete(page: Page, app_url: str):
"""Suffix can be deleted from settings."""
_go_to_exercises(page, app_url)
_clean_all(page)
# Create suffix
page.evaluate(
"""async () => {
await window.__repos.suffixes.create({
user_uuid: 'delete-suffix-uuid',
user_name: 'Dave',
suffix: 'DV',
});
}"""
)
_go_to_settings(page, app_url)
expect(page.locator('[data-testid="suffix-management"]')).to_be_visible()
# Delete with confirmation via modal dialog
page.click('[data-testid="suffix-delete-btn"]')
# Confirm deletion in the modal
expect(page.locator('[data-testid="suffix-delete-confirm"]')).to_be_visible()
page.click('[data-testid="suffix-delete-confirm"]')
page.wait_for_timeout(500)
# Suffix section should be hidden (no suffixes left)
expect(page.locator('[data-testid="suffix-management"]')).not_to_be_visible()
_clean_all(page)
# ============================================================
# Import As Mine Tests
# ============================================================
def test_import_as_mine_button_visible(page: Page, app_url: str):
"""Import-as-mine buttons are visible on exercise list and settings."""
_go_to_exercises(page, app_url)
_clean_all(page)
expect(
page.locator('[data-testid="exercise-import-as-mine-btn"]')
).to_be_visible()
_go_to_settings(page, app_url)
expect(
page.locator('[data-testid="settings-import-as-mine"]')
).to_be_visible()
_clean_all(page)
def test_import_as_mine_no_suffix_no_collision(page: Page, app_url: str):
"""Import-as-mine from a different user inserts exercises without suffix, owned by local user."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
other_uuid = "foreign-user-uuid-aaaa"
exercises = [
{
"id": "as-mine-ex-1",
"title": "Foreign Push-ups",
"description": "From another user",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 15,
"default_weight": 0,
"created_by": other_uuid,
},
{
"id": "as-mine-ex-2",
"title": "Foreign Squats",
"description": "From another user",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 20,
"default_weight": 50,
"created_by": other_uuid,
},
]
json_str = _build_export_json(exercises, "Foreign User", other_uuid)
_trigger_import_as_mine_with_json(page, json_str)
# Should go directly to preview (no suffix step)
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
expect(page.locator('[data-testid="import-suffix-step"]')).not_to_be_visible()
page.click('[data-testid="import-apply"]')
# Report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-imported"]')).to_contain_text(
"2"
)
page.click('[data-testid="import-done"]')
# Verify exercises exist with original titles (no suffix)
page.wait_for_timeout(500)
titles = page.locator('[data-testid="exercise-card-title"]')
expect(titles).to_have_count(2)
expect(titles.nth(0)).to_contain_text("Foreign")
expect(titles.nth(1)).to_contain_text("Foreign")
# Verify created_by is the local user, not the foreign user
ex1 = page.evaluate(
"""async () => await window.__repos.exercises.getById('as-mine-ex-1')"""
)
assert ex1["created_by"] == local_uuid
assert ex1["title"] == "Foreign Push-ups"
ex2 = page.evaluate(
"""async () => await window.__repos.exercises.getById('as-mine-ex-2')"""
)
assert ex2["created_by"] == local_uuid
_clean_all(page)
def test_import_as_mine_collision_replace(page: Page, app_url: str):
"""Import-as-mine with ID collision shows collision step; replace overwrites exercise."""
_go_to_exercises(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
other_uuid = "foreign-user-uuid-bbbb"
ex_id = "as-mine-collision-1"
# Create existing exercise with the same ID
_create_exercise_directly(page, "Existing Plank", None, ex_id)
exercises = [
{
"id": ex_id,
"title": "Existing Plank",
"description": "Imported version",
"asymmetric": True,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 60,
"default_weight": 0,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Foreign User", other_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)
_trigger_import_as_mine_with_json(page, json_str)
# Should see collisions step (same-user logic)
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Choose replace (exercise-specific testid)
page.click('[data-testid="collision-replace-exercise-0"]')
page.click('[data-testid="import-apply"]')
# Report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify exercise was replaced with new data, owned by local user
exercise = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{ex_id}')"""
)
assert exercise["default_reps"] == 60
assert exercise["asymmetric"] == 1 # SQLite stores bool as int
assert exercise["created_by"] == local_uuid
_clean_all(page)
def test_import_as_mine_collision_skip(page: Page, app_url: str):
"""Import-as-mine with ID collision and skip action keeps the original exercise."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "foreign-user-uuid-cccc"
ex_id = "as-mine-skip-1"
_create_exercise_directly(page, "Keep This Exercise", None, ex_id)
exercises = [
{
"id": ex_id,
"title": "Keep This Exercise",
"description": "Should not replace",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 99,
"default_weight": 99,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Foreign User", other_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)
_trigger_import_as_mine_with_json(page, json_str)
# Should see collisions step
page.wait_for_selector(
'[data-testid="import-collisions-step"]', timeout=5000
)
# Default is skip, just apply
page.click('[data-testid="import-apply"]')
# Report shows skipped
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify original unchanged
exercise = page.evaluate(
f"""async () => await window.__repos.exercises.getById('{ex_id}')"""
)
assert exercise["default_reps"] == 10
_clean_all(page)
def test_import_as_mine_from_settings(page: Page, app_url: str):
"""Import-as-mine from settings page works without suffix prompt."""
_go_to_settings(page, app_url)
_clean_all(page)
local_uuid = _get_local_uuid(page)
other_uuid = "foreign-user-uuid-dddd"
exercises = [
{
"id": "settings-as-mine-ex-1",
"title": "Settings Mine Exercise",
"description": "Via settings as mine",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 12,
"default_weight": 25,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Foreign User", other_uuid)
_trigger_settings_import_as_mine_with_json(page, json_str)
# Should go to preview directly (no suffix)
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
page.click('[data-testid="import-apply"]')
# Report
page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000)
expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible()
page.click('[data-testid="import-done"]')
# Verify exercise created and owned by local user
exercise = page.evaluate(
"""async () => {
const exercises = await window.__repos.exercises.getAll();
return exercises.find(e => e.title === 'Settings Mine Exercise');
}"""
)
assert exercise is not None
assert exercise["created_by"] == local_uuid
_clean_all(page)
def test_import_as_mine_dialog_title(page: Page, app_url: str):
"""Import-as-mine dialog shows the correct 'Import as Mine' title."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "foreign-user-uuid-eeee"
exercises = [
{
"id": "dialog-title-ex-1",
"title": "Dialog Title Test",
"description": "Test",
"asymmetric": False,
"alternate": False,
"image_urls": [],
"video_urls": [],
"default_reps": 5,
"default_weight": 0,
"created_by": other_uuid,
}
]
json_str = _build_export_json(exercises, "Test User", other_uuid)
_trigger_import_as_mine_with_json(page, json_str)
# Wait for dialog
page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000)
# Verify the dialog title says "Import as Mine"
modal_title = page.locator('[data-testid="modal-title"]')
expect(modal_title).to_contain_text("Import as Mine")
# Cancel
page.click('[data-testid="import-cancel"]')
_clean_all(page)