276 lines
12 KiB
Python
276 lines
12 KiB
Python
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}: <html> 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")
|
|
profile_body = response.content.decode("utf-8")
|
|
assert 'class="radar-illustration"' in profile_body
|
|
assert 'aria-hidden="true"' in profile_body
|
|
assert "data-radar-illustration" in profile_body
|
|
premium_css = (Path(__file__).resolve().parents[2] / "static/css/premium.css").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
app_js = (Path(__file__).resolve().parents[2] / "static/js/app.js").read_text(encoding="utf-8")
|
|
assert "prefers-reduced-motion: reduce" in premium_css
|
|
assert 'matchMedia("(prefers-reduced-motion: reduce)")' in app_js
|
|
|
|
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
|
|
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
|
|
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
|
|
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()
|