1255 lines
40 KiB
Python
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)
|