""" 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 .""" _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')