from __future__ import annotations from datetime import timedelta from decimal import Decimal from pathlib import Path import pytest from bs4 import BeautifulSoup from django.urls import reverse from django.utils import timezone from apps.jobs.models import Application, Feedback, ScoreRun try: from playwright.sync_api import sync_playwright except Exception: # pragma: no cover - optional dependency sync_playwright = None def _seed_dashboard_score(profile, job) -> None: ScoreRun.objects.create( job=job, profile=profile, profile_version=profile.version, score=Decimal("88.2"), confidence=Decimal("0.76"), recommendation=ScoreRun.Recommendation.STRONG, components={"content": 42.0, "skills": 23.0, "location": 23.2}, positives=["Relevante locatie", "Ervaring met ops"], concerns=[], hard_exclusions=[], evidence={"tests": ["vr-114"]}, model_version="vr-114", prompt_version="1.0.0", ) def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) -> None: html = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else payload soup = BeautifulSoup(html, "html.parser") headings = soup.find_all("h1") assert headings, f"{page_name}: ontbreekt h1" assert len(headings) == 1, f"{page_name}: verwacht exact één h1, gevonden {len(headings)}" assert soup.html is not None assert soup.html.get("lang"), f"{page_name}: mist lang-attribuut" for field in soup.find_all(["input", "textarea", "select"]): if field.get("type") == "hidden" or field.get("name") == "csrfmiddlewaretoken": continue field_id = field.get("id") has_label = bool(field.find_parent("label")) has_label = has_label or bool(field.get("aria-label") or field.get("aria-labelledby")) has_label = has_label or ( field_id is not None and bool(soup.find("label", attrs={"for": field_id})) ) assert has_label, ( f"{page_name}: formulierveld zonder label of aria-voorziening: " f"{field.get('name') or field.get('type')}" ) for button in soup.find_all("button"): label = button.get_text(" ", strip=True) or button.get("aria-label") assert label, f"{page_name}: knop zonder tekst of aria-label" for element in soup.select("script[src], link[rel='stylesheet'][href]"): asset_url = element.get("src") or element.get("href") or "" assert not asset_url.startswith(("http://", "https://")), ( f"{page_name}: externe productie-asset gevonden: {asset_url}" ) def _run_html_accessibility_probe(client, user, profile, job) -> None: _seed_dashboard_score(profile=profile, job=job) response = client.get(reverse("login")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="login") client.force_login(user) response = client.get(reverse("dashboard:today")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="dashboard") assert "Zoek door vacatures" in response.content.decode("utf-8") response = client.get(reverse("jobs:list"), {"q": job.original_title}) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="jobs-list") jobs_body = response.content.decode("utf-8") assert "1 vacatures gevonden" in jobs_body assert "88%" in jobs_body response = client.get(reverse("jobs:detail", kwargs={"pk": job.pk})) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="jobs-detail") response = client.get(reverse("profiles:list")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="profiles-list") response = client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="profiles-edit") response = client.get(reverse("jobs:applications")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="applications") assert "Sollicitatiepipeline" in response.content.decode("utf-8") response = client.get(reverse("sources:list")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="sources") response = client.get(reverse("dashboard:system")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="system") def _assert_playwright_accessibility(page, *, expected_h1: str) -> None: page.wait_for_load_state("networkidle") assert page.get_by_role("heading", level=1).count() == 1 assert expected_h1 in page.get_by_role("heading", level=1).all_text_contents() page.keyboard.press("Tab") focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()") assert focused_tag != "body" outline_style = page.evaluate("getComputedStyle(document.activeElement).outlineStyle") assert outline_style != "none" def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None: directory = tmp_path / "artifacts" directory.mkdir(parents=True, exist_ok=True) page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True) @pytest.mark.integration @pytest.mark.django_db def test_vr114_html_a11y_probe(client, user, profile, job): _run_html_accessibility_probe(client, user, profile, job) @pytest.mark.integration @pytest.mark.django_db @pytest.mark.parametrize( "viewport", [ {"name": "desktop", "width": 1366, "height": 900}, {"name": "mobile-360", "width": 360, "height": 780}, ], ) def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job): _seed_dashboard_score(profile=profile, job=job) if sync_playwright is None: _run_html_accessibility_probe(client, user, profile, job) pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.") base = live_server.url dashboard_url = f"{base}{reverse('dashboard:today')}" login_url = f"{base}{reverse('login')}" jobs_url = f"{base}{reverse('jobs:list')}" detail_url = f"{base}{reverse('jobs:detail', kwargs={'pk': job.pk})}" profile_list_url = f"{base}{reverse('profiles:list')}" applications_url = f"{base}{reverse('jobs:applications')}" sources_url = f"{base}{reverse('sources:list')}" with sync_playwright() as playwright: try: browser = playwright.chromium.launch(headless=True) except Exception as exc: # pragma: no cover - env-dependent _run_html_accessibility_probe(client, user, profile, job) pytest.skip(f"Playwright-browser niet beschikbaar: {exc}") context = browser.new_context( viewport={"width": viewport["width"], "height": viewport["height"]}, reduced_motion="reduce", ) page = context.new_page() try: # Login (e-mailadres + wachtwoord, Stitch-ontwerp). page.goto(login_url) _assert_playwright_accessibility(page, expected_h1="Inloggen") page.get_by_label("E-mailadres").fill(user.email) page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple") page.get_by_role("button", name="Inloggen").click() page.wait_for_url(dashboard_url) _assert_playwright_accessibility(page, expected_h1="Goedemorgen") # Dashboard -> filter -> detail. page.get_by_role("link", name="Vacatures").click() page.get_by_role("textbox", name="Zoeken").fill(job.original_title) page.get_by_role("button", name="Filters toepassen").click() assert page.url.startswith(jobs_url) page.get_by_role("link", name=job.original_title).first.click() page.wait_for_url(detail_url) _assert_playwright_accessibility(page, expected_h1=job.original_title) # Vier feedbackacties. page.get_by_role("button", name="Interessant").click() page.wait_for_selector(".message.success") page.get_by_role("button", name="Bewaren").click() page.wait_for_selector(".message.success") page.get_by_label("Reden (optioneel)").select_option("Te ver") page.get_by_role("button", name="Verbergen").click() page.wait_for_selector(".message.success") page.get_by_role("button", name="Gesolliciteerd").click() page.wait_for_selector(".message.success") # Profiel aanpassen. page.get_by_role("link", name="Zoekprofiel").click() page.wait_for_url(profile_list_url) page.get_by_role("link", name="Profiel bewerken").click() page.get_by_label("Infrastructure engineer").check() page.get_by_label("System engineer").check() page.get_by_role("button", name="Instellingen opslaan").click() page.wait_for_url(profile_list_url) # Sollicitatie dossier openen en bijwerken. application = Application.objects.filter(user=user, job=job).first() assert application is not None page.get_by_role("link", name="Sollicitaties").click() page.wait_for_url(applications_url) page.goto(f"{base}{reverse('jobs:application-edit', kwargs={'pk': application.pk})}") page.locator("select[name='status']").select_option("interview") page.locator("input[name='contact_name']").fill("Sofie Janssens") page.locator("input[name='contact_email']").fill("sofia@example.invalid") page.locator("input[name='follow_up_date']").fill( (timezone.localdate() + timedelta(days=7)).isoformat() ) page.locator("textarea[name='notes']").fill("Eerste follow-up ingepland.") page.get_by_role("button", name="Dossier opslaan").click() page.wait_for_url(applications_url) # Handmatige import via plaktekst. page.get_by_role("link", name="Bronnen").click() page.wait_for_url(sources_url) page.locator(".import-disclosure > summary").click() page.locator("textarea[name='pasted_text']").fill( "Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt" ) page.get_by_role("button", name="Import uitvoeren").click() page.wait_for_selector("text=Importresultaat") assert page.get_by_text("Modus: paste").is_visible() assert page.get_by_text("Herkenbare items").is_visible() # Keyboardvolgorde en focuseigenschappen controleren. page.keyboard.press("Tab") page.keyboard.press("Tab") page.keyboard.press("Tab") _assert_playwright_accessibility(page, expected_h1="Bronnen") assert Feedback.objects.filter(user=user, job=job).count() >= 4 except Exception: _take_failure_screenshot(page, tmp_path, viewport["name"]) raise finally: context.close() browser.close()