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

660 lines
24 KiB
Python

"""
Step 2 — Settings Tests
- test_settings_page_loads: Settings page renders all sections
- test_language_change_to_french: Switching language to French updates all labels reactively
- test_language_change_persists_reload: Language preference survives page reload
- test_username_change: Changing user name persists to database
- test_username_persists_reload: User name survives page reload
- test_username_empty_rejected: Empty name shows validation error
- test_username_whitespace_only_rejected: Whitespace-only name is rejected
- test_uuid_displayed_readonly: UUID is displayed and the field is read-only
- test_theme_toggle_dark: Switching to dark theme applies data-theme attribute
- test_theme_persists_reload: Theme preference survives page reload
- test_download_db_button: Download DB button is present and triggers a download
- test_restore_db_button: Restore DB button is visible and enabled
- test_restore_db_cancel_dialog: Declining confirm dialog aborts the restore
- test_restore_db_flow: Full restore round-trip with spinner and report
- test_restore_db_invalid_file: Uploading a non-SQLite file shows error report
- test_restore_db_incomplete_db: Uploading a DB missing required tables shows error
- test_export_import_enabled: Export/Import buttons are present and enabled (wired in Step 3b)
- test_initialize_db_button: Initialize DB button is present and enabled
- test_initialize_db_dialog_requires_checkbox: Confirm button is disabled until checkbox is checked
- test_initialize_db_dialog_cancel: Canceling the dialog does not reset the database
- test_initialize_db_flow: Full initialize flow resets database and reloads page
"""
import os
import tempfile
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_settings(page: Page, app_url: str):
"""Navigate to the settings page and wait for DB."""
_wait_for_db(page, app_url)
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-language"]')
def _download_via_button(page: Page, trigger_selector: str):
"""Click a button that opens the save-filename dialog, accept the default name, return the Download."""
page.locator(trigger_selector).click()
page.wait_for_selector('[data-testid="save-file-confirm"]')
with page.expect_download() as download_info:
page.locator('[data-testid="save-file-confirm"]').click()
return download_info.value
def test_settings_page_loads(page: Page, app_url: str):
"""Settings page renders all expected sections."""
_go_to_settings(page, app_url)
expect(page.locator('[data-testid="settings-language"]')).to_be_visible()
expect(page.locator('[data-testid="settings-username"]')).to_be_visible()
expect(page.locator('[data-testid="settings-uuid"]')).to_be_visible()
expect(page.locator('[data-testid="settings-theme"]')).to_be_visible()
expect(page.locator('[data-testid="settings-export"]')).to_be_visible()
expect(page.locator('[data-testid="settings-import"]')).to_be_visible()
def test_language_change_to_french(page: Page, app_url: str):
"""Switching language to French updates nav labels reactively."""
_go_to_settings(page, app_url)
# Change to French
page.select_option('[data-testid="settings-language"]', 'fr')
page.wait_for_function(
"window.__repos.settings.get('language').then(v => v === 'fr')",
timeout=5000,
)
# Nav labels should now be in French
expect(page.locator('a[href="#/home"] span')).to_have_text('Accueil')
expect(page.locator('a[href="#/exercises"] span')).to_have_text('Exercices')
expect(page.locator('a[href="#/settings"] span')).to_have_text('Réglages')
# Reset to English for other tests
page.select_option('[data-testid="settings-language"]', 'en')
page.wait_for_function(
"window.__repos.settings.get('language').then(v => v === 'en')",
timeout=5000,
)
def test_language_change_persists_reload(page: Page, app_url: str):
"""Language preference is persisted and restored after reload."""
_go_to_settings(page, app_url)
# Change to French and wait for DB confirmation
page.select_option('[data-testid="settings-language"]', 'fr')
page.wait_for_function(
"window.__repos.settings.get('language').then(v => v === 'fr')",
timeout=5000,
)
# Reload
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Nav should still be in French
expect(page.locator('a[href="#/home"] span')).to_have_text('Accueil')
# Navigate to settings to check the dropdown value
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-language"]')
expect(page.locator('[data-testid="settings-language"]')).to_have_value('fr')
# Reset to English
page.select_option('[data-testid="settings-language"]', 'en')
page.wait_for_function(
"window.__repos.settings.get('language').then(v => v === 'en')",
timeout=5000,
)
def test_username_change(page: Page, app_url: str):
"""Changing user name persists to the database."""
_go_to_settings(page, app_url)
username_input = page.locator('[data-testid="settings-username"]')
username_input.fill('Alice')
username_input.blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'Alice')",
timeout=5000,
)
# Verify in DB
stored = page.evaluate("async () => await window.__repos.settings.get('user_name')")
assert stored == 'Alice', f"Expected 'Alice', got '{stored}'"
# Cleanup: restore default
username_input.fill('User')
username_input.blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'User')",
timeout=5000,
)
def test_username_persists_reload(page: Page, app_url: str):
"""User name survives page reload."""
_go_to_settings(page, app_url)
username_input = page.locator('[data-testid="settings-username"]')
username_input.fill('Bob')
username_input.blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'Bob')",
timeout=5000,
)
# Reload and go back to settings
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-username"]')
expect(page.locator('[data-testid="settings-username"]')).to_have_value('Bob')
# Cleanup
username_input = page.locator('[data-testid="settings-username"]')
username_input.fill('User')
username_input.blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'User')",
timeout=5000,
)
def test_username_empty_rejected(page: Page, app_url: str):
"""Empty name shows a validation error and is not saved."""
_go_to_settings(page, app_url)
# Get original name
original = page.evaluate("async () => await window.__repos.settings.get('user_name')")
username_input = page.locator('[data-testid="settings-username"]')
username_input.fill('')
username_input.blur()
page.wait_for_timeout(500)
# Error should be visible
expect(page.locator('[data-testid="settings-name-error"]')).to_be_visible()
# DB should still have the original name
stored = page.evaluate("async () => await window.__repos.settings.get('user_name')")
assert stored == original, f"Name should not have changed, got '{stored}'"
def test_username_whitespace_only_rejected(page: Page, app_url: str):
"""Whitespace-only name is rejected."""
_go_to_settings(page, app_url)
original = page.evaluate("async () => await window.__repos.settings.get('user_name')")
username_input = page.locator('[data-testid="settings-username"]')
username_input.fill(' ')
username_input.blur()
page.wait_for_timeout(500)
expect(page.locator('[data-testid="settings-name-error"]')).to_be_visible()
stored = page.evaluate("async () => await window.__repos.settings.get('user_name')")
assert stored == original, f"Name should not have changed, got '{stored}'"
def test_uuid_displayed_readonly(page: Page, app_url: str):
"""UUID is displayed and the field is read-only."""
_go_to_settings(page, app_url)
uuid_input = page.locator('[data-testid="settings-uuid"]')
expect(uuid_input).to_be_visible()
# Should have readonly attribute
expect(uuid_input).to_have_attribute('readonly', '')
# Value should be a valid UUID
value = uuid_input.input_value()
import re
assert re.match(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
value,
), f"UUID field does not contain a valid UUID: {value}"
def test_theme_toggle_dark(page: Page, app_url: str):
"""Switching to dark theme applies the data-theme attribute on <html>."""
_go_to_settings(page, app_url)
# Switch to dark
page.select_option('[data-testid="settings-theme"]', 'dark')
page.wait_for_function(
"window.__repos.settings.get('theme').then(v => v === 'dark')",
timeout=5000,
)
theme_attr = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert theme_attr == 'dark', f"Expected data-theme='dark', got '{theme_attr}'"
# Verify CSS variables changed (bg should be dark)
bg = page.evaluate(
"getComputedStyle(document.documentElement).getPropertyValue('--bg-color1').trim()"
)
assert bg == '#1a1a1a', f"Expected dark bg '#1a1a1a', got '{bg}'"
# Reset to light
page.select_option('[data-testid="settings-theme"]', 'light')
page.wait_for_function(
"window.__repos.settings.get('theme').then(v => v === 'light')",
timeout=5000,
)
def test_theme_persists_reload(page: Page, app_url: str):
"""Theme preference survives page reload."""
_go_to_settings(page, app_url)
# Switch to dark and wait for DB confirmation
page.select_option('[data-testid="settings-theme"]', 'dark')
page.wait_for_function(
"window.__repos.settings.get('theme').then(v => v === 'dark')",
timeout=5000,
)
# Reload
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# data-theme should still be dark (applied at startup)
theme_attr = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert theme_attr == 'dark', f"Expected data-theme='dark' after reload, got '{theme_attr}'"
# Go to settings, dropdown should show dark
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-theme"]')
expect(page.locator('[data-testid="settings-theme"]')).to_have_value('dark')
# Reset to light
page.select_option('[data-testid="settings-theme"]', 'light')
page.wait_for_function(
"window.__repos.settings.get('theme').then(v => v === 'light')",
timeout=5000,
)
def test_download_db_button(page: Page, app_url: str):
"""Download DB button is present and triggers a download."""
_go_to_settings(page, app_url)
download_btn = page.locator('[data-testid="settings-download-db"]')
expect(download_btn).to_be_visible()
expect(download_btn).to_be_enabled()
# Click and verify a download is initiated
download = _download_via_button(page, '[data-testid="settings-download-db"]')
assert download.suggested_filename.startswith('trainus-')
assert download.suggested_filename.endswith('.sqlite3')
def test_restore_db_button(page: Page, app_url: str):
"""Restore DB button is visible and enabled."""
_go_to_settings(page, app_url)
restore_btn = page.locator('[data-testid="settings-restore-db"]')
expect(restore_btn).to_be_visible()
expect(restore_btn).to_be_enabled()
def test_restore_db_cancel_dialog(page: Page, app_url: str, tmp_path_factory):
"""Declining the confirm dialog aborts the restore (data unchanged)."""
_go_to_settings(page, app_url)
# Set a known username
page.locator('[data-testid="settings-username"]').fill('KeepMe')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'KeepMe')",
timeout=5000,
)
# Download DB so we have a file to upload
tmp_dir = tmp_path_factory.mktemp("cancel_dialog")
db_path = str(tmp_dir / "backup.sqlite3")
download = _download_via_button(page, '[data-testid="settings-download-db"]')
download.save_as(db_path)
# Change username after download
page.locator('[data-testid="settings-username"]').fill('Changed')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'Changed')",
timeout=5000,
)
# Dismiss the confirm dialog
page.once('dialog', lambda dialog: dialog.dismiss())
# Open file chooser and supply the downloaded file
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(db_path)
# Give a moment for any (unwanted) action — should NOT happen
page.wait_for_timeout(1000)
# Username should still be the changed value
expect(page.locator('[data-testid="settings-username"]')).to_have_value('Changed')
# No report should appear
expect(page.locator('[data-testid="restore-report"]')).not_to_be_visible()
def test_restore_db_flow(page: Page, app_url: str, tmp_path_factory):
"""Full restore: download DB, change data, restore, verify report and restored data."""
_go_to_settings(page, app_url)
# 1. Set a known username and wait for persistence
page.locator('[data-testid="settings-username"]').fill('BeforeRestore')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'BeforeRestore')",
timeout=5000,
)
# 2. Download the DB (contains 'BeforeRestore')
tmp_dir = tmp_path_factory.mktemp("restore_flow")
db_path = str(tmp_dir / "backup.sqlite3")
download = _download_via_button(page, '[data-testid="settings-download-db"]')
download.save_as(db_path)
# 3. Change username to something else
page.locator('[data-testid="settings-username"]').fill('AfterChange')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'AfterChange')",
timeout=5000,
)
# 4. Accept the confirm dialog automatically
page.once('dialog', lambda dialog: dialog.accept())
# 5. Trigger restore with the downloaded file
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(db_path)
# 6. Report should appear with success
report = page.locator('[data-testid="restore-report"]')
expect(report).to_be_visible(timeout=15000)
# Check report indicates success (green header text)
report_header = report.locator('.report-success')
expect(report_header).to_be_visible()
# 7. Username should now reflect the restored data
expect(page.locator('[data-testid="settings-username"]')).to_have_value('BeforeRestore')
# 8. Dismiss the report
page.locator('[data-testid="restore-report-dismiss"]').click()
expect(report).not_to_be_visible()
# 2. Download the DB (contains 'BeforeRestore')
tmp_dir2 = tmp_path_factory.mktemp("restore_flow2")
db_path2 = str(tmp_dir2 / "backup2.sqlite3")
download = _download_via_button(page, '[data-testid="settings-download-db"]')
download.save_as(db_path2)
# 3. Change username to something else
page.locator('[data-testid="settings-username"]').fill('AfterChange')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'AfterChange')",
timeout=5000,
)
# 4. Accept the confirm dialog automatically
page.once('dialog', lambda dialog: dialog.accept())
# 5. Trigger restore with the downloaded file
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(db_path2)
# 6. Report should appear with success
report = page.locator('[data-testid="restore-report"]')
expect(report).to_be_visible(timeout=15000)
# Debug: print report contents if error
report_text = report.text_content()
error_msg = page.locator('[data-testid="restore-error-message"]')
if error_msg.count() > 0:
assert False, f"Restore failed with error report: {report_text}"
# Check report indicates success (green header text)
report_header = report.locator('.report-success')
expect(report_header).to_be_visible()
# 7. Username should now reflect the restored data
expect(page.locator('[data-testid="settings-username"]')).to_have_value('BeforeRestore')
# 8. Dismiss the report
page.locator('[data-testid="restore-report-dismiss"]').click()
expect(report).not_to_be_visible()
def test_restore_db_invalid_file(page: Page, app_url: str):
"""Uploading a non-SQLite file shows an error report."""
_go_to_settings(page, app_url)
# Create a temp file with garbage content
tmp = tempfile.NamedTemporaryFile(suffix='.sqlite3', delete=False)
tmp.write(b'this is not a sqlite database at all')
tmp.close()
try:
# Accept the confirm dialog
page.once('dialog', lambda dialog: dialog.accept())
# Trigger restore with the invalid file
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(tmp.name)
# Report should appear with error
report = page.locator('[data-testid="restore-report"]')
expect(report).to_be_visible(timeout=10000)
# Check it's an error report
report_header = report.locator('.report-error')
expect(report_header).to_be_visible()
# Error message should be visible
expect(page.locator('[data-testid="restore-error-message"]')).to_be_visible()
finally:
os.unlink(tmp.name)
def test_restore_db_incomplete_db(page: Page, app_url: str):
"""Uploading a SQLite DB missing required tables shows an error report."""
_go_to_settings(page, app_url)
# Create a minimal SQLite DB with no TrainUs tables via the browser
# (we use page.evaluate to build bytes for a valid but empty SQLite DB)
db_bytes = page.evaluate("""
() => {
// SQLite header for an empty database (100 bytes minimum)
// We'll use a known minimal valid SQLite file
const header = new Uint8Array([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]);
// Return as array for transfer
return Array.from(header);
}
""")
# Instead use a simpler approach: create a valid empty SQLite DB file
# by letting the browser do it
tmp = tempfile.NamedTemporaryFile(suffix='.sqlite3', delete=False)
tmp.close()
# Write a valid but empty SQLite DB using sqlite3 from Python
import sqlite3
conn = sqlite3.connect(tmp.name)
conn.execute('CREATE TABLE dummy (id INTEGER PRIMARY KEY)')
conn.commit()
conn.close()
try:
page.once('dialog', lambda dialog: dialog.accept())
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(tmp.name)
# Report should appear with error
report = page.locator('[data-testid="restore-report"]')
expect(report).to_be_visible(timeout=10000)
report_header = report.locator('.report-error')
expect(report_header).to_be_visible()
expect(page.locator('[data-testid="restore-error-message"]')).to_be_visible()
finally:
os.unlink(tmp.name)
def test_export_import_enabled(page: Page, app_url: str):
"""Export and Import buttons are present and enabled (wired in Step 3b)."""
_go_to_settings(page, app_url)
export_btn = page.locator('[data-testid="settings-export"]')
import_btn = page.locator('[data-testid="settings-import"]')
expect(export_btn).to_be_visible()
expect(import_btn).to_be_visible()
expect(export_btn).to_be_enabled()
expect(import_btn).to_be_enabled()
def test_initialize_db_button(page: Page, app_url: str):
"""Initialize DB button is present and enabled."""
_go_to_settings(page, app_url)
init_btn = page.locator('[data-testid="settings-initialize-db"]')
expect(init_btn).to_be_visible()
expect(init_btn).to_be_enabled()
def test_initialize_db_dialog_requires_checkbox(page: Page, app_url: str):
"""Confirm button is disabled until the confirmation checkbox is checked."""
_go_to_settings(page, app_url)
# Open the initialize dialog
page.locator('[data-testid="settings-initialize-db"]').click()
# Dialog should be visible
dialog = page.locator('[data-testid="initialize-confirm-label"]')
expect(dialog).to_be_visible()
# Confirm button should be disabled initially
confirm_btn = page.locator('[data-testid="initialize-confirm"]')
expect(confirm_btn).to_be_disabled()
# Check the checkbox
page.locator('[data-testid="initialize-confirm-checkbox"]').check()
# Confirm button should now be enabled
expect(confirm_btn).to_be_enabled()
# Cancel the dialog
page.locator('[data-testid="initialize-cancel"]').click()
expect(dialog).not_to_be_visible()
def test_initialize_db_dialog_cancel(page: Page, app_url: str):
"""Canceling the initialize dialog does not reset the database."""
_go_to_settings(page, app_url)
# Set a known username
page.locator('[data-testid="settings-username"]').fill('TestUser')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'TestUser')",
timeout=5000,
)
# Open the initialize dialog
page.locator('[data-testid="settings-initialize-db"]').click()
# Dialog should be visible
expect(page.locator('[data-testid="initialize-confirm-label"]')).to_be_visible()
# Cancel the dialog
page.locator('[data-testid="initialize-cancel"]').click()
# Dialog should close
expect(page.locator('[data-testid="initialize-confirm-label"]')).not_to_be_visible()
# Username should still be present (database not reset)
expect(page.locator('[data-testid="settings-username"]')).to_have_value('TestUser')
def test_initialize_db_flow(page: Page, app_url: str):
"""Full initialize flow: check confirmation, click confirm, page reloads with fresh DB."""
_go_to_settings(page, app_url)
# Set a known username
page.locator('[data-testid="settings-username"]').fill('WillBeDeleted')
page.locator('[data-testid="settings-username"]').blur()
page.wait_for_function(
"window.__repos.settings.get('user_name').then(v => v === 'WillBeDeleted')",
timeout=5000,
)
# Open the initialize dialog
page.locator('[data-testid="settings-initialize-db"]').click()
# Dialog should be visible
expect(page.locator('[data-testid="initialize-confirm-label"]')).to_be_visible()
# Check the confirmation checkbox
page.locator('[data-testid="initialize-confirm-checkbox"]').check()
# Click confirm - this will trigger a page reload
page.locator('[data-testid="initialize-confirm"]').click()
# Wait for the page to reload and DB to be ready
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Navigate back to settings
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-username"]')
# Username should be reset to default 'User'
expect(page.locator('[data-testid="settings-username"]')).to_have_value('User')