Commit initial

This commit is contained in:
2026-04-15 17:57:12 +02:00
parent 005d8e797e
commit 55516ee10f
269 changed files with 26854 additions and 0 deletions
View File
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
"""E2E test fixtures: live uvicorn server for Playwright."""
from __future__ import annotations
import os
import socket
import threading
import time
import httpx
import pytest
import uvicorn
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def live_server(tmp_path_factory):
"""Start the FastAPI app on a random port in a background thread."""
# Isolated data dir for E2E session
data_dir = tmp_path_factory.mktemp("imptune_e2e_data")
# Patch config module so the app uses the temp dir
import imptune.config as cfg
cfg.DATA_DIR = str(data_dir)
cfg.DB_PATH = str(data_dir / "imptune.db")
cfg.DRIVERS_DIR = str(data_dir / "drivers")
cfg.ICONS_DIR = str(data_dir / "icons")
os.makedirs(cfg.DRIVERS_DIR, exist_ok=True)
os.makedirs(cfg.ICONS_DIR, exist_ok=True)
# Also set env var so lifespan handler picks up correct dirs
os.environ["DATA_DIR"] = str(data_dir)
# Initialize DB against the tmp dir
from imptune.db.database import init_db
init_db()
from imptune.main import app
port = _free_port()
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{port}"
# Readiness poll via /health (up to 5 s)
deadline = time.time() + 5.0
while time.time() < deadline:
try:
r = httpx.get(f"{base_url}/health", timeout=0.5)
if r.status_code == 200:
break
except Exception:
pass
time.sleep(0.1)
else:
raise RuntimeError("live_server did not become ready within 5 s")
yield base_url
server.should_exit = True
thread.join(timeout=2.0)
+132
View File
@@ -0,0 +1,132 @@
"""UIE-05: E2E tests for language toggle — FR/EN switching and localStorage persistence."""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Task 1 (Phase 12-01): navigator.language auto-detection
# ---------------------------------------------------------------------------
def test_navigator_language_en_sets_lang_en(browser, live_server: str) -> None:
"""When localStorage is empty and navigator.language is 'en-US', lang defaults to 'en'."""
context = browser.new_context(locale="en-US")
page = context.new_page()
# Clear any saved preference so localStorage fallback is not used
page.goto(f"{live_server}/", wait_until="domcontentloaded")
page.evaluate("localStorage.removeItem('imptune_lang')")
# Reload so the alpine:init IIFE runs with no localStorage and en-US locale
page.reload(wait_until="domcontentloaded")
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
timeout=3000,
)
lang = page.evaluate("Alpine.store('i18n').lang")
assert lang == "en", f"Expected 'en' when navigator.language is 'en-US', got '{lang}'"
context.close()
def test_navigator_language_fr_sets_lang_fr(browser, live_server: str) -> None:
"""When localStorage is empty and navigator.language is 'fr-FR', lang defaults to 'fr'."""
context = browser.new_context(locale="fr-FR")
page = context.new_page()
page.goto(f"{live_server}/", wait_until="domcontentloaded")
page.evaluate("localStorage.removeItem('imptune_lang')")
page.reload(wait_until="domcontentloaded")
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
timeout=3000,
)
lang = page.evaluate("Alpine.store('i18n').lang")
assert lang == "fr", f"Expected 'fr' when navigator.language is 'fr-FR', got '{lang}'"
context.close()
def test_localstorage_wins_over_navigator_language(browser, live_server: str) -> None:
"""localStorage preference wins over navigator.language when a value is saved."""
# Browser locale is 'en-US' but localStorage says 'fr' → should use 'fr'
context = browser.new_context(locale="en-US")
page = context.new_page()
page.goto(f"{live_server}/", wait_until="domcontentloaded")
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
page.reload(wait_until="domcontentloaded")
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
timeout=3000,
)
lang = page.evaluate("Alpine.store('i18n').lang")
assert lang == "fr", f"Expected localStorage 'fr' to win over navigator.language 'en-US', got '{lang}'"
# Cleanup
page.evaluate("localStorage.removeItem('imptune_lang')")
context.close()
# ---------------------------------------------------------------------------
# Original toggle and persistence tests
# ---------------------------------------------------------------------------
def test_language_toggle_switches_nav_label(page, live_server: str) -> None:
"""Clicking FR/EN button switches nav label from French to English."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
# Default lang is 'fr' — nav should show French labels
# Wait for Alpine to hydrate
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
timeout=3000,
)
# In French, printers nav label = 'Imprimantes'
printers_label_fr = page.text_content("nav a[href='/printers']").strip()
assert printers_label_fr == "Imprimantes", f"Expected 'Imprimantes', got '{printers_label_fr}'"
# Click the language toggle button
page.click("button[title='Toggle language']")
# Wait for label to update
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
timeout=2000,
)
printers_label_en = page.text_content("nav a[href='/printers']").strip()
assert printers_label_en == "Printers"
# Cleanup: reset to fr
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
def test_language_persists_across_reload(page, live_server: str) -> None:
"""After switching to EN, language is preserved on page reload."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
# Switch to English
page.click("button[title='Toggle language']")
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
timeout=2000,
)
# Reload
page.reload(wait_until="domcontentloaded")
page.wait_for_function(
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
timeout=3000,
)
label_after_reload = page.text_content("nav a[href='/printers']").strip()
assert label_after_reload == "Printers"
# Cleanup: reset to fr
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
+29
View File
@@ -0,0 +1,29 @@
"""UX-02: live-browser verification of PRNT-03 Alpine IP->port auto-derivation."""
from __future__ import annotations
import pytest
def test_port_autofill(page, live_server: str) -> None:
"""Typing an IP address into the printer form auto-populates port_name.
Evidence for UX-02: tests/e2e/test_port_autofill.py
Closes: PRNT-03 Alpine.js port auto-derivation requirement.
"""
# /printers/new renders a full page (extends base.html) with Alpine.js loaded
# and the add-printer form (moved from /printers to /printers/new in Plan 11-01)
page.goto(f"{live_server}/printers/new", wait_until="domcontentloaded")
# Wait for Alpine to initialise (x-data hydration on the form wrapper)
page.wait_for_selector("input[name='ip_address']")
# Fill triggers the 'input' event that Alpine @input listens to
page.fill("input[name='ip_address']", "192.168.1.100")
# Alpine @input reacts synchronously; wait_for_function keeps the test stable
page.wait_for_function(
"document.querySelector(\"input[name='port_name']\").value === 'IP_192_168_1_100'",
timeout=2000,
)
assert page.input_value("input[name='port_name']") == "IP_192_168_1_100"
+62
View File
@@ -0,0 +1,62 @@
"""UIE-01: E2E test for printer edit modal — open, pre-fill, submit, list update."""
from __future__ import annotations
import pytest
def test_printer_edit_modal_open_and_prefill(page, live_server: str) -> None:
"""Edit button opens modal with printer's current name pre-filled."""
import httpx
# Create a printer via API
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
api.post(
"/printers",
data={
"name": "EditTest Printer",
"ip_address": "10.0.5.1",
"port_name": "IP_10_0_5_1",
},
)
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
page.wait_for_selector("button:has-text('Edit')")
page.click("button:has-text('Edit')")
# Dialog should be open
page.wait_for_selector("dialog[open]")
# Name input should be pre-filled
name_val = page.input_value("dialog[open] input[name='name']")
assert name_val == "EditTest Printer"
def test_printer_edit_submit_updates_list(page, live_server: str) -> None:
"""Submitting the edit form updates the printer name in the list (no page reload)."""
import httpx
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
resp = api.post(
"/printers",
data={
"name": "OriginalName",
"ip_address": "10.0.5.2",
"port_name": "IP_10_0_5_2",
},
)
# Find the printer ID from the DB via API — target the specific Edit button by printer ID
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
# Find the row containing "OriginalName" and click its Edit button
row = page.locator("tr", has=page.locator("a", has_text="OriginalName"))
row.wait_for()
row.locator("button:has-text('Edit')").click()
page.wait_for_selector("dialog[open]")
# Clear and update the name field
page.fill("dialog[open] input[name='name']", "UpdatedName")
page.click("dialog[open] button[type='submit']")
# Wait for HTMX to swap the updated list — the anchor for the printer should show new name
page.wait_for_selector("a:has-text('UpdatedName')")
assert page.locator("a:has-text('UpdatedName')").is_visible()
assert page.locator("a:has-text('OriginalName')").count() == 0
+50
View File
@@ -0,0 +1,50 @@
"""UIE-04: E2E tests for theme toggle — data-theme cycling and localStorage persistence."""
from __future__ import annotations
import pytest
def test_theme_cycles_on_click(page, live_server: str) -> None:
"""Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
# Initial state: auto (default from base.html)
initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert initial_theme == "auto"
# Click once -> light
page.click("button[aria-label='auto']")
page.wait_for_function(
"document.documentElement.getAttribute('data-theme') === 'light'",
timeout=2000,
)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "light"
# Click again -> dark
page.click("button[aria-label='light']")
page.wait_for_function(
"document.documentElement.getAttribute('data-theme') === 'dark'",
timeout=2000,
)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
def test_theme_persists_across_reload(page, live_server: str) -> None:
"""After clicking theme toggle, the chosen theme is restored on reload."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
# Switch to light mode
page.click("button[aria-label='auto']")
page.wait_for_function(
"document.documentElement.getAttribute('data-theme') === 'light'",
timeout=2000,
)
# Reload the page
page.reload(wait_until="domcontentloaded")
# Theme should still be light (from localStorage)
theme_after_reload = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert theme_after_reload == "light"
# Cleanup: reset to auto
page.evaluate("localStorage.setItem('imptune_theme', 'auto')")