|
1 | 1 | """Playwright integration test for the generated corpus decoder HTML.""" |
2 | 2 |
|
3 | 3 | from pathlib import Path |
| 4 | +from typing import Literal |
4 | 5 |
|
5 | 6 | import pytest |
6 | 7 |
|
@@ -57,3 +58,115 @@ def test_upload_repository_corpus(tmp_path: Path) -> None: |
57 | 58 | assert rows.count() == expected_tlvs |
58 | 59 |
|
59 | 60 | browser.close() |
| 61 | + |
| 62 | + |
| 63 | +@pytest.mark.skipif(sync_playwright is None, reason="Playwright not installed") |
| 64 | +@pytest.mark.parametrize("scheme", ["light", "dark"]) |
| 65 | +def test_accessibility_after_upload_in_light_and_dark(tmp_path: Path, scheme: Literal["light", "dark"]) -> None: |
| 66 | + """Basic accessibility smoke: after upload, key elements are visible in both schemes. |
| 67 | +
|
| 68 | + This test toggles prefers-color-scheme and checks that: |
| 69 | + - The dark/light CSS actually applies (by inspecting body background color in dark) |
| 70 | + - Headings and summary items remain present |
| 71 | + - A coarse contrast check (>= 3.0) passes between body background and heading text |
| 72 | + to catch regressions where text becomes unreadable. |
| 73 | + """ |
| 74 | + html_path = tmp_path / "index.html" |
| 75 | + generate_html(html_path) |
| 76 | + |
| 77 | + corpus_path = _example_corpus() |
| 78 | + expected_tlvs = _expected_tlvs(corpus_path) |
| 79 | + |
| 80 | + file_url = html_path.resolve().as_uri() |
| 81 | + |
| 82 | + if sync_playwright is None: |
| 83 | + pytest.skip("Playwright not installed") |
| 84 | + |
| 85 | + with sync_playwright() as playwright: |
| 86 | + browser = playwright.chromium.launch() |
| 87 | + page = browser.new_page() |
| 88 | + page.emulate_media(color_scheme=scheme) # Apply requested color scheme |
| 89 | + page.goto(file_url) |
| 90 | + |
| 91 | + # Upload corpus and wait for summary |
| 92 | + page.set_input_files("#corpus-input", str(corpus_path)) |
| 93 | + page.wait_for_selector(f"text=Decoded {expected_tlvs} TLVs successfully.") |
| 94 | + |
| 95 | + # Verify headings and summary exist |
| 96 | + assert page.locator("header h1").count() == 1 |
| 97 | + assert page.locator("#summary-count").inner_text().strip() == str(expected_tlvs) |
| 98 | + |
| 99 | + # Page-wide contrast sweep over visible text nodes; collect failures (< 3.0) |
| 100 | + results = page.evaluate( |
| 101 | + r""" |
| 102 | + () => { |
| 103 | + function parseColor(c) { |
| 104 | + const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/); |
| 105 | + if (!m) return {r:0,g:0,b:0,a:1}; |
| 106 | + return { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] }; |
| 107 | + } |
| 108 | + function blend(top, bottom) { |
| 109 | + // Alpha composite 'top' over 'bottom'; both are {r,g,b,a} with a in [0,1] |
| 110 | + const a = top.a + bottom.a * (1 - top.a); |
| 111 | + const r = Math.round((top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / (a || 1)); |
| 112 | + const g = Math.round((top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / (a || 1)); |
| 113 | + const b = Math.round((top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / (a || 1)); |
| 114 | + return { r, g, b, a: 1 }; |
| 115 | + } |
| 116 | + function srgbToLin(v) { |
| 117 | + v /= 255; |
| 118 | + return v <= 0.04045 ? v/12.92 : Math.pow((v + 0.055)/1.055, 2.4); |
| 119 | + } |
| 120 | + function relLuma({r,g,b}) { |
| 121 | + const R = srgbToLin(r), G = srgbToLin(g), B = srgbToLin(b); |
| 122 | + return 0.2126*R + 0.7152*G + 0.0722*B; |
| 123 | + } |
| 124 | + function isVisible(el) { |
| 125 | + const cs = getComputedStyle(el); |
| 126 | + const rect = el.getBoundingClientRect(); |
| 127 | + return rect.width > 0 && rect.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0.05; |
| 128 | + } |
| 129 | + function bodyBg() { |
| 130 | + let b = parseColor(getComputedStyle(document.body).backgroundColor); |
| 131 | + if (b.a === 0) b = { r: 255, g: 255, b: 255, a: 1 }; |
| 132 | + return b; |
| 133 | + } |
| 134 | + function effectiveBackground(el) { |
| 135 | + if (!el) return bodyBg(); |
| 136 | + const cs = getComputedStyle(el); |
| 137 | + const bg = parseColor(cs.backgroundColor); |
| 138 | + if (bg.a === 0) return effectiveBackground(el.parentElement); |
| 139 | + const parentBg = effectiveBackground(el.parentElement); |
| 140 | + if (bg.a >= 1) return bg; |
| 141 | + return blend(bg, parentBg); |
| 142 | + } |
| 143 | + const nodes = Array.from(document.querySelectorAll('*')); |
| 144 | + const failures = []; |
| 145 | + let scanned = 0; |
| 146 | + for (const el of nodes) { |
| 147 | + if (!isVisible(el)) continue; |
| 148 | + const text = (el.textContent || '').trim(); |
| 149 | + if (!text) continue; |
| 150 | + const cs = getComputedStyle(el); |
| 151 | + let fg = parseColor(cs.color); |
| 152 | + const bg = effectiveBackground(el); |
| 153 | + if (fg.a === 0) continue; // fully transparent text |
| 154 | + if (fg.a < 1) fg = blend(fg, bg); |
| 155 | + const L1 = relLuma(fg); |
| 156 | + const L2 = relLuma(bg); |
| 157 | + const contrast = (Math.max(L1,L2)+0.05) / (Math.min(L1,L2)+0.05); |
| 158 | + scanned += 1; |
| 159 | + if (contrast < 3.0) { |
| 160 | + failures.push({ tag: el.tagName.toLowerCase(), text: text.slice(0, 60), contrast: Math.round(contrast*100)/100 }); |
| 161 | + } |
| 162 | + } |
| 163 | + return { scanned, failures, minContrast: failures.length ? Math.min(...failures.map(f=>f.contrast)) : null }; |
| 164 | + } |
| 165 | + """ |
| 166 | + ) |
| 167 | + assert results and isinstance(results, dict) |
| 168 | + assert results.get("scanned", 0) > 0 |
| 169 | + failed = results.get("failures", []) |
| 170 | + assert not failed, f"Low contrast elements in {scheme} mode: {failed[:3]}{(' …' if len(failed) > 3 else '')}" |
| 171 | + |
| 172 | + browser.close() |
0 commit comments