diff --git a/docs/superpowers/plans/2026-08-07-wcag-skrot-i-graf.md b/docs/superpowers/plans/2026-08-07-wcag-skrot-i-graf.md new file mode 100644 index 000000000..06b988131 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-wcag-skrot-i-graf.md @@ -0,0 +1,1600 @@ +# Skrót `/` i nawigacja po grafie — plan wdrożenia + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Domknąć kryteria WCAG 2.1.4 (mechanizm wyłączania skrótu `/`), +2.5.7 (nawigacja po grafie powiązań wskaźnikiem) i 2.1.1 (ta sama nawigacja +klawiaturą). + +**Architecture:** Dwa niezależne moduły JS window-globalnych, wzorem +`related-records-highlight.js` z poprzedniej iteracji. Pierwszy trzyma +preferencję skrótów w `localStorage` i obsługuje przełącznik w stopce; +handlery `/` pytają go przed reakcją. Drugi wystawia czyste funkcje nad +instancją Cytoscape (`przesun`, `zoomuj`, `dopasuj`), z których korzystają +zarówno przyciski nakładki, jak i handler klawiatury. + +**Tech Stack:** Django (szablony), JavaScript (moduły window-globalne, bez +bundlera dla `skroty-klawiszowe.js`; `nawigacja.js` idzie przez esbuild do +`cytoscape-bundle.js`), Cytoscape.js ^3.34.0, vitest, pytest, Playwright, +SCSS + grunt. + +**Spec:** `docs/superpowers/specs/2026-08-07-wcag-skrot-i-graf-design.md` + +## Global Constraints + +- Wszystkie polecenia Pythona przez `uv run` — nigdy gołe `python`/`pytest`. +- Max długość linii Pythona: 88 znaków (ruff). JS i szablonów nie dotyczy. +- Testy: pytest, funkcje standalone (nie klasy), `@pytest.mark.django_db` + gdzie potrzebna baza; `model_bakery.baker.make` do tworzenia obiektów. +- Testy JS: vitest, pliki w `tests/js/**/*.test.js`. Domyślne środowisko to + `node` — plik testujący kod używający `window`/`localStorage` musi + deklarować u siebie `// @vitest-environment jsdom`. +- Komentarze Django `{# … #}` **jednoliniowe** — każda linia własne + otwarcie i zamknięcie. Wieloliniowy wycieka do wyrenderowanego HTML-a. +- **NIE modyfikować istniejących migracji** w `src/*/migrations/`. +- `pre-commit` uruchamiać BEZ ARGUMENTÓW. Nigdy `--all-files`, nigdy + `ruff check --fix` — problemy naprawiać ręcznie, po jednym. +- Na hoście mogą działać cudze kontenery testowe: NIE uruchamiać + `make clean-testcontainers`. +- Praca w worktree `/Volumes/SSD/Programowanie/bpp-wcag-faza2`, gałąź + `fix-wcag-2140-2570` (stacked na `fix-wcag-naprawy-stwierdzone`). +- Po zmianach SCSS: `grunt build` (albo `make assets`). + +## Struktura plików + +**Tworzone:** + +| plik | odpowiedzialność | +|---|---| +| `src/bpp/static/bpp/js/skroty-klawiszowe.js` | preferencja skrótów + przełącznik | +| `tests/js/skroty-klawiszowe.test.js` | testy vitest modułu preferencji | +| `src/powiazania_autorow/static/powiazania_autorow/js/powiazania/nawigacja.js` | czyste funkcje nawigacji nad Cytoscape | +| `tests/js/nawigacja-grafu.test.js` | testy vitest modułu nawigacji | +| `src/bpp/static/scss/graf_powiazan.scss` | nakładka nawigacji + pierścień focusa | +| `src/bpp/tests/test_wcag/test_skrot_szukania.py` | testy szablonowe części A | +| `src/powiazania_autorow/tests/__init__.py` | pakiet testów (jeśli nie istnieje) | +| `src/powiazania_autorow/tests/test_graf_dostepnosc.py` | testy szablonowe grafu | +| `src/integration_tests/test_wcag_skrot_i_graf.py` | testy Playwright | +| `src/bpp/newsfragments/wcag-skrot-i-graf.feature.rst` | newsfragment | + +**Modyfikowane:** + +| plik | zmiana | +|---|---| +| `src/django_bpp/templates/base.html:37-51` | **usunięcie** zduplikowanego handlera `/`; dodanie ` +``` + +Bez `defer` — moduł musi być dostępny, gdy handlery sprawdzają preferencję +przy naciśnięciu klawisza, a przełącznik w stopce podpina się po +`DOMContentLoaded`. + +- [ ] **Step 4: Usuń zduplikowany handler z `base.html`** + +W `src/django_bpp/templates/base.html` usuń CAŁY blok (linie 37-51): + +```django + +``` + +Handler w `global_search_modal.html` robi to samo i dodatkowo sprawdza stan +modala. Zostawienie obu oznacza dwa wywołania `openGlobalSearch` na jedno +naciśnięcie klawisza. + +- [ ] **Step 5: Dodaj warunek w `global_search_modal.html`** + +W `src/django_bpp/templates/global_search_modal.html`, linie 1047-1052, +zamień: + +```javascript + // Check for "/" key (forward slash) + if (e.key === '/' && !$('#globalSearchModal').hasClass('show')) { + e.preventDefault(); + openGlobalSearch(null, true); // Pass true to indicate keyboard trigger + } +``` + +na: + +```javascript + // Check for "/" key (forward slash) + // WCAG 2.1.4: skrot jednoznakowy musi dac sie wylaczyc. + // Guard `typeof` — gdy modul sie nie zaladuje, degradujemy do + // zachowania dotychczasowego zamiast rzucac TypeError. + var skrotyWl = (typeof window.bppSkrotyWlaczone !== 'function' + || window.bppSkrotyWlaczone()); + if (e.key === '/' && skrotyWl + && !$('#globalSearchModal').hasClass('show')) { + e.preventDefault(); + openGlobalSearch(null, true); // Pass true to indicate keyboard trigger + } +``` + +- [ ] **Step 6: Dodaj warunek w `browse/uczelnia.html`** + +Dwie zmiany w `src/bpp/templates/browse/uczelnia.html`. + +Pierwsza — handler zamykający baner (linie 134-141), zamień: + +```javascript + document.addEventListener('keydown', function(e) { + if (e.key === '/' && !$(e.target).is('input, textarea, select')) { +``` + +na: + +```javascript + document.addEventListener('keydown', function(e) { + var skrotyWl = (typeof window.bppSkrotyWlaczone !== 'function' + || window.bppSkrotyWlaczone()); + if (e.key === '/' && skrotyWl && !$(e.target).is('input, textarea, select')) { +``` + +Druga — pokazywanie banera (linie 102-108). Baner reklamuje skrót +(„Wciśnij klawisz `/`…"), więc przy wyłączonych skrótach nie może się +pojawiać. Zamień: + +```javascript + if (!bannerDismissed || (dismissedDate && daysSince(dismissedDate) > 30)) { +``` + +na: + +```javascript + // WCAG 2.1.4: nie reklamuj skrotu, ktory uzytkownik wylaczyl. + var skrotyWl = (typeof window.bppSkrotyWlaczone !== 'function' + || window.bppSkrotyWlaczone()); + if (skrotyWl + && (!bannerDismissed || (dismissedDate && daysSince(dismissedDate) > 30))) { +``` + +- [ ] **Step 7: Dodaj przycisk w stopce** + +W `src/django_bpp/templates/base_footer.html`, wewnątrz +`` zamykającym `#graf-wrapper`) +- Modify: `src/powiazania_autorow/static/powiazania_autorow/js/powiazania/controls.js` +- Create: `src/bpp/static/scss/graf_powiazan.scss` +- Modify: `src/bpp/static/scss/common.scss` +- Create: `src/powiazania_autorow/tests/__init__.py` (jeśli nie istnieje) +- Test: `src/powiazania_autorow/tests/test_graf_dostepnosc.py` + +**Interfaces:** +- Consumes: `przesun`, `zoomuj`, `dopasuj` z Task 3 +- Produces: siedem przycisków o identyfikatorach `graf-nav-gora`, + `graf-nav-dol`, `graf-nav-lewo`, `graf-nav-prawo`, `graf-nav-zoom-in`, + `graf-nav-zoom-out`, `graf-nav-dopasuj` (używane w Task 6) + +- [ ] **Step 1: Sprawdź, czy pakiet testów istnieje** + +```bash +ls src/powiazania_autorow/tests/__init__.py 2>/dev/null \ + || (mkdir -p src/powiazania_autorow/tests && touch src/powiazania_autorow/tests/__init__.py) +``` + +Uwaga: istniejące testy tej aplikacji leżą płasko +(`src/powiazania_autorow/test_views.py`), ale nowy plik dotyczy dostępności i +zyskuje na wydzieleniu. + +- [ ] **Step 2: Napisz testy szablonowe** + +Utwórz `src/powiazania_autorow/tests/test_graf_dostepnosc.py`: + +```python +"""Dostępność grafu powiązań: nawigacja wskaźnikiem (2.5.7) i klawiaturą (2.1.1). + +Graf stoi na Cytoscape.js i do tej pory dawał się przesuwać wyłącznie +przeciąganiem. Kryterium 2.5.7 wymaga alternatywy realizowanej pojedynczym +wskaźnikiem — stąd przyciski. Sam kontener zyskuje `tabindex`, żeby te same +funkcje dało się wywołać z klawiatury (2.1.1). +""" + +from pathlib import Path + +SZABLON = ( + Path(__file__).resolve().parents[1] + / "templates" + / "powiazania_autorow" + / "graf.html" +) + +PRZYCISKI = [ + "graf-nav-gora", + "graf-nav-dol", + "graf-nav-lewo", + "graf-nav-prawo", + "graf-nav-zoom-in", + "graf-nav-zoom-out", + "graf-nav-dopasuj", +] + + +def _tresc(): + return SZABLON.read_text(encoding="utf-8") + + +def test_wszystkie_przyciski_nawigacji_obecne(): + tresc = _tresc() + for identyfikator in PRZYCISKI: + assert f'id="{identyfikator}"' in tresc, f"brak przycisku {identyfikator}" + + +def test_kazdy_przycisk_ma_aria_label(): + # Sam glif strzałki nic nie mówi czytnikowi ekranu. + tresc = _tresc() + fragmenty = tresc.split("` zamykającym wrapper, wstaw: + +```django + {# WCAG 2.5.7: nawigacja bez przeciagania. Prawy dolny rog to #} + {# jedyny wolny — lewy gorny zajmuje #graf-panel, prawy gorny #} + {# #graf-legenda, lewy dolny #graf-notka (przy przycietej sieci). #} +
+ + + + + + + +
+``` + +- [ ] **Step 5: Utwórz partial SCSS** + +Utwórz `src/bpp/static/scss/graf_powiazan.scss`: + +```scss +// Nakladka nawigacji po grafie powiazan (WCAG 2.5.7) oraz pierscien focusa +// kontenera grafu (2.4.7). Aplikacja powiazania_autorow nie ma wlasnego +// katalogu SCSS — graf.html opisuje wyglad atrybutami style="" — ale +// :focus-visible wymaga arkusza, wiec komponent siedzi w calosci tutaj. + +.graf-nawigacja { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 1001; + + display: grid; + grid-template-columns: repeat(3, auto); + gap: 2px; + + padding: 6px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid #ddd; + border-radius: 4px; +} + +.graf-nawigacja__btn { + // WCAG 2.5.8 (Target Size Minimum): cel dotykowy nie mniejszy niz + // 24x24 px CSS. Naprawiajac 2.5.7 nie wolno zlamac sasiedniego + // kryterium. + min-width: 28px; + min-height: 28px; + + padding: 0; + margin: 0; + font-size: 15px; + line-height: 1; + + background: #fff; + border: 1px solid #ccc; + border-radius: 3px; + color: #333; + cursor: pointer; + + &:hover { + background: #f0f0f0; + } + + &:focus-visible { + outline: 2px solid #2c6cb0; + outline-offset: 1px; + } +} + +// WCAG 2.4.7: uzytkownik klawiatury musi widziec, ze graf przejal focus — +// inaczej nie wie, ze strzalki zaczely dzialac. +#cytoscape-container:focus-visible { + outline: 3px solid #2c6cb0; + outline-offset: -3px; +} +``` + +- [ ] **Step 6: Zaimportuj partial** + +W `src/bpp/static/scss/common.scss`, obok pozostałych importów (linie 7-18), +dopisz: + +```scss +@import "graf_powiazan"; +``` + +- [ ] **Step 7: Podepnij przyciski w `controls.js`** + +W +`src/powiazania_autorow/static/powiazania_autorow/js/powiazania/controls.js` +dopisz import na górze, obok istniejących: + +```javascript +import { przesun, zoomuj, dopasuj } from "./nawigacja.js"; +``` + +Na końcu funkcji `podepnijZdarzenia(ctx)`, przed zamykającym `}`, dopisz: + +```javascript + // --- nawigacja bez przeciagania (WCAG 2.5.7) --- + const KIERUNKI = { + "graf-nav-gora": "gora", + "graf-nav-dol": "dol", + "graf-nav-lewo": "lewo", + "graf-nav-prawo": "prawo" + }; + Object.keys(KIERUNKI).forEach(function (id) { + const btn = document.getElementById(id); + if (btn) { + btn.addEventListener("click", function () { + przesun(cy, KIERUNKI[id]); + }); + } + }); + + const btnZoomIn = document.getElementById("graf-nav-zoom-in"); + if (btnZoomIn) { + btnZoomIn.addEventListener("click", function () { + zoomuj(cy, 1.2); + }); + } + + const btnZoomOut = document.getElementById("graf-nav-zoom-out"); + if (btnZoomOut) { + btnZoomOut.addEventListener("click", function () { + zoomuj(cy, 1 / 1.2); + }); + } + + const btnDopasuj = document.getElementById("graf-nav-dopasuj"); + if (btnDopasuj) { + btnDopasuj.addEventListener("click", function () { + dopasuj(cy); + }); + } +``` + +- [ ] **Step 8: Przebuduj front i uruchom testy** + +```bash +grunt build +uv run pytest src/powiazania_autorow/tests/test_graf_dostepnosc.py -v +npx vitest run tests/js/nawigacja-grafu.test.js +``` + +Oczekiwane: 4 passed (pytest), 12 passed (vitest). + +- [ ] **Step 9: Commit** + +```bash +git add src/powiazania_autorow/templates/powiazania_autorow/graf.html \ + src/powiazania_autorow/static/powiazania_autorow/js/powiazania/controls.js \ + src/bpp/static/scss/graf_powiazan.scss \ + src/bpp/static/scss/common.scss \ + src/powiazania_autorow/tests/ +git commit -m "feat(wcag): przyciski nawigacji po grafie bez przeciagania (2.5.7)" +``` + +--- + +### Task 5: Obsługa grafu klawiaturą + +**Files:** +- Modify: `src/powiazania_autorow/templates/powiazania_autorow/graf.html:158-166` (atrybuty kontenera) +- Modify: `src/powiazania_autorow/static/powiazania_autorow/js/powiazania/controls.js` (handler `keydown`) +- Test: `src/powiazania_autorow/tests/test_graf_dostepnosc.py` (dopisanie) + +**Interfaces:** +- Consumes: `przesun`, `zoomuj`, `dopasuj` z Task 3 +- Produces: nic dla dalszych zadań + +- [ ] **Step 1: Dopisz testy** + +Do `src/powiazania_autorow/tests/test_graf_dostepnosc.py` dopisz: + +```python +def test_kontener_grafu_jest_fokusowalny(): + # Bez tabindex użytkownik klawiatury nigdy nie dotrze do grafu. + tresc = _tresc() + fragment = tresc.split('id="cytoscape-container"')[1][:600] + + assert 'tabindex="0"' in fragment + + +def test_kontener_grafu_ma_role_application(): + # Bez tego czytnik ekranu w trybie przeglądania sam obsłuży strzałki + # i nigdy nie dotrą one do grafu. + tresc = _tresc() + fragment = tresc.split('id="cytoscape-container"')[1][:600] + + assert 'role="application"' in fragment + + +def test_kontener_grafu_opisuje_dostepne_klawisze(): + # role="application" wycisza tryb przeglądania, więc aria-label jest + # jedynym sposobem, w jaki użytkownik pozna dostępne klawisze. + tresc = _tresc() + fragment = tresc.split('id="cytoscape-container"')[1][:600] + + assert "aria-label=" in fragment + for slowo in ("trzałk", "Home"): + assert slowo in fragment +``` + +- [ ] **Step 2: Uruchom testy — muszą paść** + +```bash +uv run pytest src/powiazania_autorow/tests/test_graf_dostepnosc.py -v +``` + +Oczekiwane: trzy nowe FAIL. + +- [ ] **Step 3: Dodaj atrybuty kontenera** + +W `src/powiazania_autorow/templates/powiazania_autorow/graf.html`, element +`#cytoscape-container` (linie 158-166), dopisz trzy atrybuty przed +`style=`: + +```django + tabindex="0" + role="application" + aria-label="Graf powiązań autorów. Strzałki przesuwają widok, plus i minus przybliżają, klawisz Home dopasowuje graf do ekranu." +``` + +- [ ] **Step 4: Dodaj handler klawiatury w `controls.js`** + +Na końcu funkcji `podepnijZdarzenia(ctx)`, po kodzie przycisków z Task 4, +dopisz: + +```javascript + // --- obsluga klawiatura (WCAG 2.1.1) --- + // Klawisze `+`/`-` sa znakami drukowalnymi, wiec podlegaja tez 2.1.4 — + // spelniaja je trzecim wariantem kryterium: dzialaja WYLACZNIE gdy + // kontener grafu ma focus, bo handler wisi na nim, nie na `document`. + const kontener = document.getElementById("cytoscape-container"); + if (kontener) { + kontener.addEventListener("keydown", function (e) { + let obsluzone = true; + + switch (e.key) { + case "ArrowUp": przesun(cy, "gora"); break; + case "ArrowDown": przesun(cy, "dol"); break; + case "ArrowLeft": przesun(cy, "lewo"); break; + case "ArrowRight": przesun(cy, "prawo"); break; + case "+": + case "=": zoomuj(cy, 1.2); break; + case "-": + case "_": zoomuj(cy, 1 / 1.2); break; + case "Home": dopasuj(cy); break; + default: obsluzone = false; + } + + // preventDefault WYLACZNIE dla obsluzonych klawiszy. Blokowanie + // wszystkiego zamknelo by Tab w grafie, czyli naprawiajac 2.1.1 + // stworzylibysmy pulapke klawiaturowa i zlamali 2.1.2. + if (obsluzone) { + e.preventDefault(); + } + }); + } +``` + +Warianty `=` i `_` są obok `+` i `-`, bo na większości układów klawiatury +`+` wymaga Shift. + +- [ ] **Step 5: Przebuduj front i uruchom testy** + +```bash +grunt build +uv run pytest src/powiazania_autorow/tests/test_graf_dostepnosc.py -v +``` + +Oczekiwane: 7 passed. + +- [ ] **Step 6: Commit** + +```bash +git add src/powiazania_autorow/templates/powiazania_autorow/graf.html \ + src/powiazania_autorow/static/powiazania_autorow/js/powiazania/controls.js \ + src/powiazania_autorow/tests/test_graf_dostepnosc.py +git commit -m "feat(wcag): obsluga grafu powiazan klawiatura (2.1.1, 2.4.7)" +``` + +--- + +### Task 6: Testy Playwright + +Testy szablonowe i jednostkowe nie dowodzą, że mechanizm **działa**. Bez +tego zadania usunięcie warunku z handlera nie wywaliłoby niczego. + +**Files:** +- Create: `src/integration_tests/test_wcag_skrot_i_graf.py` + +**Interfaces:** +- Consumes: `#bpp-przelacznik-skrotow` (Task 2), `#graf-nav-*` (Task 4), + `tabindex` na `#cytoscape-container` (Task 5) +- Produces: nic + +- [ ] **Step 1: Poznaj wzorzec z tego projektu** + +Sygnatura testu przeglądarkowego w BPP (z +`src/integration_tests/test_siec3d_bez_webgl.py:44-57`): + +```python +@pytest.mark.django_db(transaction=True) +def test_cos(channels_live_server, page: Page, transactional_db): + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski", pokazuj=True) + url = reverse("bpp:browse_autor_powiazania", args=[autor.pk]) + page.goto(f"{channels_live_server.url}{url}", wait_until="domcontentloaded") +``` + +Trzy fixture: `channels_live_server`, `page: Page`, `transactional_db`. +Autora tworzy się przez `baker.make(Autor, …, pokazuj=True)` — nie ma +gotowej fixture autora dla tych testów. URL składa się przez f-string, bez +`+`. Import: `from playwright.sync_api import Page, expect`. + +**Wymaganie wstępne:** `make assets` — bez zbudowanego bundla strona nie ma +czego wykonać i testy padną. + +- [ ] **Step 2: Napisz testy** + +Utwórz `src/integration_tests/test_wcag_skrot_i_graf.py`: + +```python +"""WCAG 2.1.4 i 2.5.7/2.1.1 — testy zachowania w przeglądarce. + +Testy szablonowe dowodzą, że kod jest w pliku; te dowodzą, że działa. +Bez nich usunięcie warunku `bppSkrotyWlaczone()` z handlera albo +`preventDefault` z obsługi klawiatury nie wywaliłoby żadnego testu. +""" + +import pytest +from django.urls import reverse +from model_bakery import baker +from playwright.sync_api import Page, expect + +from bpp.models import Autor + + +def _url_autora(channels_live_server): + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski", pokazuj=True) + return ( + f"{channels_live_server.url}" + f"{reverse('bpp:browse_autor', args=[autor.slug])}" + ) + + +@pytest.mark.django_db(transaction=True) +def test_skrot_otwiera_wyszukiwarke_domyslnie( + channels_live_server, page: Page, transactional_db +): + page.goto(_url_autora(channels_live_server), wait_until="domcontentloaded") + + page.keyboard.press("/") + + expect(page.locator("#globalSearchModal")).to_be_visible(timeout=5000) + + +@pytest.mark.django_db(transaction=True) +def test_wylaczenie_skrotu_dziala( + channels_live_server, page: Page, transactional_db +): + page.goto(_url_autora(channels_live_server), wait_until="domcontentloaded") + + page.locator("#bpp-przelacznik-skrotow").click() + page.keyboard.press("/") + page.wait_for_timeout(500) + + expect(page.locator("#globalSearchModal")).not_to_be_visible() + + +@pytest.mark.django_db(transaction=True) +def test_ponowne_wlaczenie_przywraca_skrot( + channels_live_server, page: Page, transactional_db +): + page.goto(_url_autora(channels_live_server), wait_until="domcontentloaded") + + przelacznik = page.locator("#bpp-przelacznik-skrotow") + przelacznik.click() + przelacznik.click() + page.keyboard.press("/") + + expect(page.locator("#globalSearchModal")).to_be_visible(timeout=5000) + + +@pytest.mark.django_db(transaction=True) +def test_przelacznik_aktualizuje_aria_pressed( + channels_live_server, page: Page, transactional_db +): + page.goto(_url_autora(channels_live_server), wait_until="domcontentloaded") + + przelacznik = page.locator("#bpp-przelacznik-skrotow") + expect(przelacznik).to_have_attribute("aria-pressed", "true") + + przelacznik.click() + expect(przelacznik).to_have_attribute("aria-pressed", "false") + + +@pytest.mark.django_db(transaction=True) +def test_preferencja_przezywa_przeladowanie( + channels_live_server, page: Page, transactional_db +): + url = _url_autora(channels_live_server) + page.goto(url, wait_until="domcontentloaded") + + page.locator("#bpp-przelacznik-skrotow").click() + page.reload(wait_until="domcontentloaded") + + expect(page.locator("#bpp-przelacznik-skrotow")).to_have_attribute( + "aria-pressed", "false" + ) +``` + +Widok strony autora został wybrany zamiast strony uczelni, bo nie wymaga +obiektu `Uczelnia` w bazie, a stopka i modal wyszukiwarki renderują się na +nim tak samo. `bpp:browse_autor` ma dwa warianty URL-a — po `pk` +(`urls.py:274`) i po `slug` (`urls.py:300`); `reverse` z argumentem `slug` +trafia w drugi. `Autor` tworzony przez `baker.make` dostaje slug +automatycznie (pole wyliczane przy zapisie). + +**Nie zamieniaj** testu zachowania na sprawdzenie obecności elementu w +HTML-u — testy szablonowe z Task 2 już to pokrywają, ten plik ma dowodzić +działania. + +- [ ] **Step 3: Uruchom testy** + +```bash +make assets +make playwright-install +uv run pytest src/integration_tests/test_wcag_skrot_i_graf.py -v +``` + +Pierwszy przebieg bywa wolny (zimny start testcontenerów) i `page.goto` +potrafi raz timeoutnąć — wtedy ponów. + +Jeżeli nazwa fixture live-servera albo widoku (`bpp:browse_uczelnia`) nie +pasuje, dostosuj ją do wzorca z kroku 1 — ale **nie** zamieniaj testu +zachowania na test obecności elementu w HTML-u. Testy szablonowe już to +pokrywają; ten plik ma dowodzić działania. + +Jeżeli strona uczelni nie renderuje przełącznika (np. wymaga obiektu +`Uczelnia` w bazie), utwórz go w teście przez `baker.make` albo użyj +istniejącej fixture — sprawdź `src/fixtures/conftest_*.py`. + +- [ ] **Step 4: Dopisz test grafu** + +Do tego samego pliku dopisz: + +```python +def _url_grafu(channels_live_server): + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski", pokazuj=True) + return ( + f"{channels_live_server.url}" + f"{reverse('bpp:browse_autor_powiazania', args=[autor.pk])}" + ) + + +@pytest.mark.django_db(transaction=True) +def test_graf_ma_przyciski_nawigacji_i_jest_fokusowalny( + channels_live_server, page: Page, transactional_db +): + page.goto(_url_grafu(channels_live_server), wait_until="domcontentloaded") + + kontener = page.locator("#cytoscape-container") + expect(kontener).to_have_attribute("tabindex", "0") + expect(page.locator("#graf-nav-dopasuj")).to_be_visible(timeout=10000) + + page.locator("#graf-nav-dopasuj").click() + + +@pytest.mark.django_db(transaction=True) +def test_graf_nie_jest_pulapka_klawiaturowa( + channels_live_server, page: Page, transactional_db +): + # Handler robi preventDefault WYŁĄCZNIE dla obsłużonych klawiszy. Gdyby + # blokował wszystko, Tab przestałby wyprowadzać focus — czyli naprawiając + # 2.1.1 stworzylibyśmy pułapkę klawiaturową i złamali 2.1.2. + page.goto(_url_grafu(channels_live_server), wait_until="domcontentloaded") + + page.locator("#cytoscape-container").focus() + page.keyboard.press("Tab") + + assert page.evaluate("document.activeElement.id") != "cytoscape-container" +``` + +Widok `bpp:browse_autor_powiazania` przyjmuje `pk` — potwierdzone w +`test_siec3d_bez_webgl.py:67`, gdzie ten sam URL jest budowany przez +`reverse("bpp:browse_autor_powiazania", args=[autor.pk])`. + +Jeżeli graf nie renderuje się dla autora bez powiązań i przyciski nie +pojawią się w DOM, sprawdź warunek widoczności sieci +(`Autor.czy_pokazywac_siec_powiazan`) i ustaw wymagane pole przez +`baker.make`. Nakładka jest w szablonie statycznie, więc powinna być obecna +niezależnie od danych — jeśli tak nie jest, zgłoś to jako znalezisko, bo +oznaczałoby, że nawigacja znika akurat przy pustym grafie. + +- [ ] **Step 5: Uruchom całość i commit** + +```bash +uv run pytest src/integration_tests/test_wcag_skrot_i_graf.py -v +git add src/integration_tests/test_wcag_skrot_i_graf.py +git commit -m "test(wcag): testy przegladarkowe skrotu i nawigacji po grafie" +``` + +--- + +### Task 7: Wykaz odroczonych i newsfragment + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md` +- Modify: `docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md` +- Create: `src/bpp/newsfragments/wcag-skrot-i-graf.feature.rst` + +**Interfaces:** +- Consumes: nic +- Produces: nic + +- [ ] **Step 1: Usuń 2.1.4 i 2.5.7 z wykazu w dokumencie 08-06** + +W sekcji „Wykaz odroczonych niezgodności" zastąp wpisy o 2.1.4 i 2.5.7 +jednym akapitem: + +```markdown +**2.1.4 i 2.5.7 — domknięte 2026-08-07.** +Oba kryteria zostały naprawione w kolejnej iteracji: skrót `/` dostał +mechanizm wyłączania (localStorage + przełącznik w stopce), graf powiązań — +przyciski nawigacji i obsługę klawiaturą. Szczegóły: +`2026-08-07-wcag-skrot-i-graf-design.md`. +``` + +- [ ] **Step 2: To samo w dokumencie 08-05** + +Ten sam akapit w sekcji „Odroczone niezgodności". + +- [ ] **Step 3: Przejrzyj oba dokumenty pod kątem pozostałych wzmianek** + +Poprzednia iteracja pokazała, że dokument o rozbudowanej historii zostawia +twierdzenia sprzeczne ze stanem faktycznym w miejscach, o których się nie +myśli — trzy kolejne przejścia znajdowały kolejne. + +```bash +grep -n "2\.1\.4\|2\.5\.7\|skrót\|skrot\|graf\|przeciąganie" \ + docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md \ + docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md +``` + +Dla KAŻDEGO trafienia rozstrzygnij, czy twierdzenie jest nadal prawdziwe. +Miejsca mówiące „odroczone", „niezgodne", „świadomie nie naprawiamy" dla +tych dwóch kryteriów wymagają dopisku +`**Korekta (2026-08-07):**` — zgodnie z konwencją przyjętą w tych +dokumentach, bez usuwania oryginalnego tekstu. + +W raporcie wymień **wszystkie** sprawdzone miejsca, także uznane za +niewymagające zmiany, z uzasadnieniem. + +- [ ] **Step 4: Napisz newsfragment** + +Utwórz `src/bpp/newsfragments/wcag-skrot-i-graf.feature.rst`: + +```rst +Skrót klawiszowy ``/`` otwierający wyszukiwarkę można teraz wyłączyć — +przełącznik „Skróty klawiszowe" znajduje się w stopce strony. Ma to +znaczenie dla osób korzystających ze sterowania głosem, którym pojedyncze +znaki mimowolnie uruchamiały wyszukiwarkę (kryterium WCAG 2.2 AA 2.1.4). +Przy okazji naprawiono zdublowany obsługiwacz tego skrótu. + +Graf powiązań autorów da się teraz przesuwać i przybliżać przyciskami oraz +klawiaturą (strzałki, plus, minus, Home), a nie wyłącznie przeciąganiem +myszą — kryteria 2.5.7 i 2.1.1. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/ src/bpp/newsfragments/wcag-skrot-i-graf.feature.rst +git commit -m "docs(wcag): domkniecie 2.1.4 i 2.5.7 w wykazie, newsfragment" +``` + +--- + +### Task 8: Weryfikacja końcowa + +- [ ] **Step 1: Pełna suita Pythona** + +```bash +cd /Volumes/SSD/Programowanie/bpp-wcag-faza2 +make tests-without-playwright +``` + +Trwa do ~10 minut. Poczekaj w swojej turze, nie uruchamiaj w tle. + +- [ ] **Step 2: Testy JS** + +```bash +make js-tests +``` + +- [ ] **Step 3: Testy Playwright** + +```bash +make assets +make tests-only-playwright +``` + +- [ ] **Step 4: pre-commit** + +```bash +pre-commit +``` + +BEZ ARGUMENTÓW. Problemy naprawiaj ręcznie, edytorem, po jednym. + +- [ ] **Step 5: Spójność gałęzi** + +```bash +git log --oneline fix-wcag-naprawy-stwierdzone..HEAD +git diff --stat fix-wcag-naprawy-stwierdzone..HEAD +uv run python src/manage.py makemigrations --check --dry-run +``` + +Gałąź jest **stacked** na `fix-wcag-naprawy-stwierdzone`, więc bazą +porównania jest ta gałąź, nie `dev`. + +Dryf migracji w pakietach zewnętrznych (favicon, flexible_reports, siteblog) +jest zastany — zweryfikowano to w poprzedniej iteracji. Dryf w pakietach +`bpp`/`powiazania_autorow` byłby znaleziskiem: zgłoś, nie twórz migracji. + +- [ ] **Step 6: Obejrzyj efekt w przeglądarce** + +```bash +uv run run-site run --from-dump ~/db-backup-20260428-093811.pg_dump --no-browser +``` + +Sprawdź: przełącznik w stopce zmienia etykietę i faktycznie wyłącza skrót; +na stronie autora z włączoną siecią powiązań przyciski przesuwają graf, a +Tab dochodzi do kontenera i strzałki działają. diff --git a/docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md b/docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md index eb3c12ebf..bb8dd1053 100644 --- a/docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md +++ b/docs/superpowers/specs/2026-08-05-wcag-22-aa-zgodnosc-frontendu-design.md @@ -510,6 +510,12 @@ naprawione — patrz sekcja „Odroczone niezgodności". Formuła „naprawa stwierdzona → B…" dla `3.1.2` niżej jest poprawna i pozostaje bez zmian — tam naprawa faktycznie zaszła. +**Korekta (2026-08-07):** odroczenie z korekty wyżej okazało się +tymczasowe — oba kryteria naprawiono dzień później +(`2026-08-07-wcag-skrot-i-graf-design.md`). Pierwotne brzmienie wierszy +`2.1.4` i `2.5.7` w tabeli („naprawa stwierdzona → B1/B5") jest więc znów +aktualne: audyt ma te naprawy zweryfikować, nie odnotować niezgodność. + ### Zrozumiałość | Kryterium | Poz. | Źródło werdyktu | @@ -555,6 +561,12 @@ naprawione — decyzje i uzasadnienia w sekcji „Odroczone niezgodności". „Zadanie brzmi »napraw«" było zamiarem na starcie tej sekcji, nie jest opisem tego, co się faktycznie stało z każdym z czterech kryteriów. +**Korekta (2026-08-07):** ostatecznie naprawiono wszystkie cztery. 2.1.4 +i 2.5.7 domknęła kolejna iteracja +(`2026-08-07-wcag-skrot-i-graf-design.md`), więc pierwotne „Zadanie brzmi +»napraw«" opisuje jednak to, co się stało — tyle że w dwóch podejściach, +nie w jednym. + ### 2.1.4 Character Key Shortcuts (A) — skrót `/` `base.html:39-49` wiąże jednoznakowy skrót `/` na poziomie `document`. @@ -572,6 +584,14 @@ przeniesiony w zasięg focusa pola wyszukiwania. **Korekta (2026-08-06):** kryterium **odroczono**, nie naprawiono. Skrót `/` działa dokładnie tak samo jak w opisie wyżej — bez zmian w kodzie. + +**Korekta (2026-08-07):** kryterium **naprawione**. Z trzech dopuszczonych +wyjść wybrano pierwsze — wyłączalność. Preferencja siedzi w `localStorage` +(nie w profilu, więc działa też dla anonima), przełącznik „Skróty +klawiszowe" jest w stopce, a wszystkie trzy handlery `/` sprawdzają ją +przed otwarciem wyszukiwarki. Opis w akapitach wyżej („Obecny kod nie +spełnia żadnego") dotyczy stanu sprzed tej naprawy. Szczegóły: +`2026-08-07-wcag-skrot-i-graf-design.md`. Rekomendacja z sekcji „Otwarte decyzje" (zawężenie do focusa pola wyszukiwania) nie została wdrożona. Wpis w wykazie: sekcja „Odroczone niezgodności". @@ -677,6 +697,16 @@ jednowskaźnikowej. Powód: koszt nieproporcjonalny do pozostałych napraw w tej iteracji, funkcja opcjonalna i w części wdrożeń wyłączona. Wpis w wykazie: sekcja „Odroczone niezgodności". +**Korekta (2026-08-07):** kryterium **naprawione**, i to obiema drogami +wymienionymi wyżej. Graf dostał siedem przycisków nawigacji (cztery +kierunki, przybliż, oddal, dopasuj) oraz obsługę klawiaturą (strzałki, +`+`/`-`, `Home`) na fokusowalnym kontenerze — przy okazji domyka to +2.1.1 dla tego widoku. Handler robi `preventDefault` wyłącznie dla +obsłużonych klawiszy, żeby nie stworzyć pułapki klawiaturowej (2.1.2), +i przepuszcza kombinacje z Ctrl/Cmd/Alt, żeby nie przechwycić zoomu +przeglądarki (1.4.4). Szczegóły: +`2026-08-07-wcag-skrot-i-graf-design.md`. + ## Hipotezy do zbadania w audycie Poniższe **nie są stwierdzonymi naruszeniami**. To miejsca, w których @@ -908,6 +938,11 @@ nie da się ułożyć: wyżej), a `2.1.4` i `2.5.7` zostały świadomie odroczone, nie naprawione (sekcja „Odroczone niezgodności"). Szczegóły: `2026-08-06-wcag-naprawy-stwierdzone-design.md`. + **Korekta (2026-08-07):** krok (3) domknięto w drugim podejściu — `2.1.4` + i `2.5.7` naprawiono nazajutrz + (`2026-08-07-wcag-skrot-i-graf-design.md`). Cały krok „Naprawy + stwierdzone" jest więc wykonany; kroki (4)–(7) nie mają już na co czekać + po tej stronie. 4. **Baseline freeze.** Dopiero **po** (3) i po zamrożeniu próbki. Kolejność jest istotna: baseline zakładany przed naprawami zaksięgowałby dług, który zaraz znika, i wymuszałby natychmiastową aktualizację pliku. @@ -939,6 +974,14 @@ globalny, nadal niewyłączalny), wpis w wykazie niezgodności zamiast implementacji zawężenia do focusa. Powód i szczegóły: sekcja „Odroczone niezgodności". +**Korekta (2026-08-07):** rekomendacja nadal nie została przyjęta, ale +kryterium **naprawiono inaczej** — przez wyłączalność, nie przez zawężenie +do focusa. Założenie stojące za rekomendacją („dwa pierwsze wyjścia +wymagają interfejsu ustawień, a ten nie istnieje dla anonima") okazało się +zbyt ostrożne: preferencja w `localStorage` plus jeden przycisk w stopce +wystarczą, bez budowania profilu użytkownika. Skrót działa więc nadal +globalnie — dla tych, którzy go nie wyłączyli. + **Motywy uczelniane w raporcie silnika.** `vizja`, `mwsl`, `uafm` to motywy konkretnych klientów, nie warianty produktu. Jeśli test (b) wykaże w nich zły kontrast, naprawa **zmienia branding uczelni** — decyzja nie @@ -1041,22 +1084,15 @@ Zapisane tutaj, bo raport zgodności — właściwe miejsce takich wpisów — jeszcze nie istnieje. Szczegóły i uzasadnienia: `2026-08-06-wcag-naprawy-stwierdzone-design.md`. -**2.1.4 Character Key Shortcuts (A) — skrót `/`.** -Handler w `src/django_bpp/templates/base.html:39-49` wiąże `/` na -`document`, wykluczając jedynie `input`/`textarea`/`select`. Nie spełnia -żadnego z trzech warunków kryterium (wyłączalny, przemapowywalny, aktywny -tylko przy focusie). Stan: **niezgodne, świadomie odroczone**. Powód: brak -nacisku regulacyjnego i brak odbiorcy raportu; wszystkie trzy dopuszczone -wyjścia mają koszt produktowy (utrata skrótu globalnego albo zbudowanie -interfejsu preferencji dla użytkownika anonimowego). - -**2.5.7 Dragging Movements (AA) — graf powiązań.** -`src/powiazania_autorow/templates/powiazania_autorow/graf.html`, widok -publiczny bramkowany per uczelnia (`czy_pokazywac_siec_powiazan`, -`src/bpp/views/browse.py:245`). Nawigacja wyłącznie przez przeciąganie. -Stan: **niezgodne, świadomie odroczone**. Powód: koszt nieproporcjonalny do -pozostałych napraw w tej iteracji, a funkcja jest opcjonalna i w części -wdrożeń wyłączona. +Wpisy, które przestały być aktualne, zostają w sekcji z datą domknięcia +zamiast zniknąć — inaczej nie dałoby się odtworzyć, co i kiedy uznano za +niezgodność. + +**2.1.4 i 2.5.7 — domknięte 2026-08-07.** +Oba kryteria zostały naprawione w kolejnej iteracji: skrót `/` dostał +mechanizm wyłączania (localStorage + przełącznik w stopce), graf powiązań — +przyciski nawigacji i obsługę klawiaturą. Szczegóły: +`2026-08-07-wcag-skrot-i-graf-design.md`. **3.1.2 — tytuł przełożony (`tytul`).** Stan: **spełnione częściowo**. Oznaczamy tytuł oryginalny; przekład zostaje diff --git a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md index dd25824d9..28a3307d8 100644 --- a/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md +++ b/docs/superpowers/specs/2026-08-06-wcag-naprawy-stwierdzone-design.md @@ -40,6 +40,11 @@ i raportu bez odbiorcy byłoby produkcją artefaktu, którego nikt nie czyta. - 2.5.7 — nawigacja po grafie powiązań - 3.1.2 dla tytułu przełożonego (`tytul`) — patrz „Decyzja o polu `jezyk_alt`" +**Korekta (2026-08-07):** 2.1.4 i 2.5.7 nie zostały ostatecznie odroczone — +domknęła je kolejna iteracja (`2026-08-07-wcag-skrot-i-graf-design.md`). +Zakres tej iteracji, opisany wyżej, pozostaje bez zmian; nieaktualna jest +tylko prognoza, że oba kryteria zostaną z niego trwale wyłączone. + **Nie wchodzi (poza tą iteracją):** - bramka CI z axe-core, baseline, strona wzorników @@ -499,24 +504,15 @@ miejsce. Zapisujemy je w specyfikacji z 2026-08-05 (nowa sekcja „Odroczone niezgodności"), bo to dokument, który przyszły audyt przeczyta jako pierwszy; issue na GitHubie by zaginęło. -Każdy wpis zawiera kryterium, stan, uzasadnienie i datę decyzji. - -**2.1.4 Character Key Shortcuts (A) — skrót `/`.** -Handler w `src/django_bpp/templates/base.html:39-49` wiąże `/` na -`document`, wykluczając jedynie `input`/`textarea`/`select`. Nie spełnia -żadnego z trzech warunków kryterium (wyłączalny, przemapowywalny, aktywny -tylko przy focusie). Stan: **niezgodne, świadomie odroczone**. Powód: brak -nacisku regulacyjnego i brak odbiorcy raportu; wszystkie trzy dopuszczone -wyjścia mają koszt produktowy (utrata skrótu globalnego albo zbudowanie -interfejsu preferencji dla użytkownika anonimowego). - -**2.5.7 Dragging Movements (AA) — graf powiązań.** -`src/powiazania_autorow/templates/powiazania_autorow/graf.html`, widok -publiczny bramkowany per uczelnia (`czy_pokazywac_siec_powiazan`, -`src/bpp/views/browse.py:245`). Nawigacja wyłącznie przez przeciąganie. -Stan: **niezgodne, świadomie odroczone**. Powód: koszt nieproporcjonalny do -pozostałych napraw w tej iteracji, a funkcja jest opcjonalna i w części -wdrożeń wyłączona. +Każdy wpis zawiera kryterium, stan, uzasadnienie i datę decyzji. Wpisy, +które przestały być aktualne, zostają z datą domknięcia zamiast zniknąć — +inaczej nie dałoby się odtworzyć, co i kiedy uznano za niezgodność. + +**2.1.4 i 2.5.7 — domknięte 2026-08-07.** +Oba kryteria zostały naprawione w kolejnej iteracji: skrót `/` dostał +mechanizm wyłączania (localStorage + przełącznik w stopce), graf powiązań — +przyciski nawigacji i obsługę klawiaturą. Szczegóły: +`2026-08-07-wcag-skrot-i-graf-design.md`. **3.1.2 — tytuł przełożony (`tytul`).** Stan: **spełnione częściowo**. Oznaczamy tytuł oryginalny; przekład zostaje diff --git a/docs/superpowers/specs/2026-08-07-wcag-skrot-i-graf-design.md b/docs/superpowers/specs/2026-08-07-wcag-skrot-i-graf-design.md new file mode 100644 index 000000000..6aeac8ba1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-wcag-skrot-i-graf-design.md @@ -0,0 +1,425 @@ +# Skrót `/` i nawigacja po grafie powiązań — WCAG 2.1.4, 2.5.7, 2.1.1 + +Data: 2026-08-07 +Poprzednik: `2026-08-06-wcag-naprawy-stwierdzone-design.md` +Gałąź: `fix-wcag-2140-2570`, stacked na `fix-wcag-naprawy-stwierdzone` (PR #732) + +## Cel + +Domknąć dwa kryteria WCAG 2.2 AA, które poprzednia iteracja **świadomie +odroczyła**, wpisując je do wykazu niezgodności: + +- **2.1.4 Character Key Shortcuts (A)** — skrót `/` wiązany na `document` +- **2.5.7 Dragging Movements (AA)** — nawigacja po grafie powiązań wyłącznie + przez przeciąganie + +Przy okazji grafu domykamy też **2.1.1 Keyboard (A)** — obsługa klawiaturą +kosztuje niewiele, gdy funkcje nawigacyjne i tak powstają dla przycisków. + +Odroczenie z 2026-08-06 miało uzasadnienie: brak nacisku regulacyjnego i brak +odbiorcy raportu. Ta iteracja jest decyzją, żeby mimo to poprawić dostępność +tam, gdzie koszt jest znany i ograniczony. + +## Zakres + +**Wchodzi:** + +- 2.1.4 — mechanizm wyłączania skrótów jednoznakowych (localStorage), + obejmujący **wszystkie** publiczne handlery `/`, nie jeden +- likwidacja zduplikowanego handlera `/` (dziś `openGlobalSearch` woła się + dwa razy na jedno naciśnięcie) — znalezisko poboczne, naprawiane przy okazji +- 2.5.7 — przyciski nawigacji po grafie (kierunki, zoom, dopasowanie) +- 2.1.1 — obsługa grafu klawiaturą (strzałki, `+`/`−`, `Home`) +- 2.4.7 — widoczny pierścień focusa na kontenerze grafu +- usunięcie obu kryteriów z wykazu odroczonych w obu specyfikacjach + +**Nie wchodzi:** + +- 3.1.2 dla tytułu przełożonego (wymaga nowego pola w modelu) +- bramka CI z axe-core, skan szeroki, audyt ręczny, raport zgodności +- 2.5.8 (rozmiar celów dotykowych) w skali całego serwisu — ale **nowe** + przyciski muszą spełniać to kryterium, patrz „Część B" + +## Część A — 2.1.4, skrót `/` + +### Problem + +Wykluczenie pól formularza nie wystarcza: gdy focus spoczywa na `body`, +linku albo przycisku, naciśnięcie `/` porywa klawisz. Kryterium wymaga +spełnienia **jednego z trzech** warunków — wyłączalności, przemapowania albo +aktywności wyłącznie przy focusie komponentu. Żaden z istniejących handlerów +nie spełnia żadnego. + +Kryterium chroni przede wszystkim użytkowników sterowania głosem, których +wejściem są ciągi liter, oraz osoby z drżeniem rąk. + +### Inwentaryzacja: skrót `/` obsługiwany jest w czterech miejscach + +To ustalenie jest warunkiem skuteczności całej części A. Objęcie preferencją +jednego handlera nie da nic, dopóki pozostałe reagują niezależnie. + +| miejsce | co robi | w zakresie? | +|---|---|---| +| `src/django_bpp/templates/base.html:42` | otwiera wyszukiwarkę globalną | **tak** | +| `src/django_bpp/templates/global_search_modal.html:1048` | otwiera wyszukiwarkę globalną (**duplikat**) | **tak** | +| `src/bpp/templates/browse/uczelnia.html:134` | zamyka baner podpowiedzi | **tak** | +| `src/django_bpp/templates/admin/base_site.html:127` | wyszukiwarka w adminie | nie — panel zalogowanego jest poza zakresem audytu | + +Modal jest włączany z `top_bar.html:511`, a ten z `base.html`, więc oba +pierwsze handlery wiszą na `document` **na tej samej stronie**. + +**Znalezisko poboczne: dziś oba wołają `openGlobalSearch` na jedno +naciśnięcie.** Handler w `base.html` nie sprawdza stanu modala, handler w +modalu sprawdza (`!$('#globalSearchModal').hasClass('show')`), ale przy +zamkniętym modalu oba warunki są prawdziwe. To istniejący błąd, niezależny +od WCAG. + +### Wybrane wyjście: mechanizm wyłączania + +Wzorce branżowe potwierdzają ten kierunek. GitHub trzyma to jako ustawienie +konta („You can disable character key shortcuts, while still allowing +shortcuts that use modifier keys, in your accessibility settings"), Gmail +udostępnia dialog pod `?` z przełącznikiem. + +Obie realizacje zakładają jednak **zalogowanego użytkownika z profilem**, a +audytowany zakres BPP to część publiczna, anonimowa. Funkcjonalnym +odpowiednikiem jest `localStorage`: nie wymaga konta, przeżywa sesję, działa +per przeglądarka. Kryterium nie wymaga trwałości między urządzeniami — tylko +istnienia mechanizmu. + +Odrzucone alternatywy: + +- **zawężenie do focusa w polu wyszukiwania** — zgodne, ale skrót traci sens: + mając focus w wyszukiwarce wystarczy pisać; +- **modyfikator (`Alt+/`)** — wyprowadza skrót spoza kryterium jednym + ruchem, ale łamie konwencję branżową (`/` w GitHub, Gmail, Slack), a + `Alt+/` bywa zajęty przez czytniki ekranu; +- **całkowite usunięcie** — pełna zgodność zerowym kosztem, ale odbiera + działającą wygodę użytkownikom klawiatury; +- **przełącznik w obiekcie `Uczelnia`** — wyłączenie przez administratora + **nie spełnia** kryterium: WCAG wymaga mechanizmu dostępnego dla + użytkownika. + +### Komponenty + +**Moduł preferencji** — `src/bpp/static/bpp/js/skroty-klawiszowe.js`. +Odpowiedzialność: preferencja użytkownika i jej przełącznik. + +- klucz `localStorage`: `bpp.skrotyJednoznakowe`, wartości `"1"` / `"0"` +- `window.bppSkrotyWlaczone()` → `boolean`, **domyślnie `true`** (brak wpisu + = zachowanie niezmienione dla każdego, kto nic nie ustawił); wartość inna + niż `"0"`/`"1"` traktowana jak brak wpisu +- `window.bppUstawSkroty(bool)` → zapisuje i zwraca nowy stan +- `window.bppPodepnijPrzelacznikSkrotow(el)` → wiąże `click` na przekazanym + elemencie, aktualizuje jego tekst i `aria-pressed` +- odporny na niedostępny `localStorage` (tryb prywatny, wyczerpany limit, + wyłączone ciasteczka): `try`/`catch` wokół obu operacji, w razie błędu + zwraca wartość domyślną zamiast rzucać wyjątkiem + +Trzecia funkcja jest w module celowo: gdyby logika przełącznika została +inline w stopce, nie dałoby się jej przetestować jednostkowo, a moduł +znałby tylko połowę własnego kontraktu. + +**Handlery** — warunek dodany w **obu** publicznych miejscach otwierających +wyszukiwarkę oraz w handlerze banera: + +```javascript +if (e.key === '/' + && (typeof window.bppSkrotyWlaczone !== 'function' + || window.bppSkrotyWlaczone()) + && !$(e.target).is('input, textarea, select')) { +``` + +Guard `typeof` jest konieczny: gdyby moduł się nie załadował (404, błąd +wcześniejszego skryptu), gołe wywołanie rzucałoby `TypeError` przy każdym +naciśnięciu `/` i skrót umarłby po cichu. Wzorzec jest zgodny z istniejącym +`typeof openGlobalSearch === 'function'` w tym samym handlerze +(`base.html:44`). Przy braku modułu degradujemy do zachowania dotychczasowego. + +**Likwidacja duplikatu.** Handler w `base.html:39-49` i ten w +`global_search_modal.html:1040-1052` robią to samo. Zostaje **jeden** — ten +w `global_search_modal.html`, bo sprawdza już stan modala i mieszka obok +kodu, który obsługuje. Handler z `base.html` znika w całości. + +To upraszcza część A: zamiast trzech miejsc do objęcia warunkiem zostają +dwa (modal + baner na stronie uczelni). + +**Kolejność ładowania nie jest krytyczna.** Handlery rejestrują się wewnątrz +`DOMContentLoaded`, ale preferencję sprawdzają dopiero w momencie +naciśnięcia klawisza — więc moduł musi być obecny przy pierwszym użyciu +skrótu, nie przy rejestracji. Mimo to ładujemy go w `base.html` przed +blokiem skryptów, dla przewidywalności. + +**Baner podpowiedzi.** `src/django_bpp/templates/search_banner.html:9` +reklamuje skrót („Wciśnij klawisz `/` (ukośnik) aby szybko znaleźć…"). +Przy wyłączonych skrótach baner **nie może się pokazywać** — instrukcja +używania niedziałającej funkcji jest gorsza niż brak instrukcji. Moduł +ukrywa baner, gdy preferencja jest wyłączona. + +**Przełącznik w stopce** — `src/django_bpp/templates/base_footer.html`. + +Przycisk (` + {# Klucz do odczytu grafu stoi TUTAJ, nie w nakladce #graf-legenda: #} + {# legenda jest chowana ponizej 640 px (graf_powiazan.scss), a bez tego #} + {# zdania uzytkownik telefonu widzi kola roznej wielkosci i linie roznej #} + {# grubosci, nie wiedzac, co znacza. Chowanie TRESCI na malym ekranie to #} + {# strata informacji, nie dekoracji — czyli dokladnie to, przed czym #} + {# stoi 1.3.1. W legendzie zostaja wylacznie podpowiedzi o najechaniu #} + {# kursorem, ktorych na ekranie dotykowym i tak nie ma. #} Na każdym węźle pokazujemy najpierw współautorów z największą liczbą wspólnych publikacji. Klik w węzeł dowija jego dalszych współautorów. + Wielkość koła odpowiada wybranej metryce (liczba prac / IF / PK), + a grubość linii — liczbie wspólnych publikacji.
Sieć powiązań — {{ autor }} color: #444; pointer-events: none; z-index: 1001;"> Najedź na węzeł, aby zobaczyć autora; najedź na linię, aby zobaczyć liczbę wspólnych publikacji. Kliknij węzeł, aby rozwinąć jego - współautorów i otworzyć menu akcji. Wielkość koła odpowiada wybranej - metryce (liczba prac / IF / PK), a grubość linii — liczbie wspólnych - publikacji. + współautorów i otworzyć menu akcji.
diff --git a/src/powiazania_autorow/test_graf_dostepnosc.py b/src/powiazania_autorow/test_graf_dostepnosc.py new file mode 100644 index 000000000..6f1b95f87 --- /dev/null +++ b/src/powiazania_autorow/test_graf_dostepnosc.py @@ -0,0 +1,98 @@ +"""Dostępność grafu powiązań: nawigacja wskaźnikiem (2.5.7) i klawiaturą (2.1.1). + +Graf stoi na Cytoscape.js i do tej pory dawał się przesuwać wyłącznie +przeciąganiem. Kryterium 2.5.7 wymaga alternatywy realizowanej pojedynczym +wskaźnikiem — stąd przyciski. Sam kontener zyskuje `tabindex`, żeby te same +funkcje dało się wywołać z klawiatury (2.1.1). +""" + +from pathlib import Path + +import powiazania_autorow + +# Kotwiczymy na katalogu PAKIETU, nie na `__file__` testu: ścieżka liczona +# przez `parents[n]` łamie się przy każdym przeniesieniu pliku testowego, a +# ten plik już raz zmienił miejsce (mieszkał w `tests/`, która to nazwa +# przesłaniała istniejący moduł `powiazania_autorow.tests` i wywalała +# kolekcję pytesta). +SZABLON = ( + Path(powiazania_autorow.__file__).resolve().parent + / "templates" + / "powiazania_autorow" + / "graf.html" +) + +PRZYCISKI = [ + "graf-nav-gora", + "graf-nav-dol", + "graf-nav-lewo", + "graf-nav-prawo", + "graf-nav-zoom-in", + "graf-nav-zoom-out", + "graf-nav-dopasuj", +] + + +def _tresc(): + return SZABLON.read_text(encoding="utf-8") + + +def test_wszystkie_przyciski_nawigacji_obecne(): + tresc = _tresc() + for identyfikator in PRZYCISKI: + assert f'id="{identyfikator}"' in tresc, f"brak przycisku {identyfikator}" + + +def test_kazdy_przycisk_ma_aria_label(): + # Sam glif strzałki nic nie mówi czytnikowi ekranu. + tresc = _tresc() + fragmenty = tresc.split(" (opcje.width === undefined ? 1000 : opcje.width), + height: () => (opcje.height === undefined ? 500 : opcje.height), + minZoom: () => 0.1, + maxZoom: () => 4, + zoom: function (arg) { + if (arg === undefined) { + return stan.poziomZoom; + } + stan.zoomArg = arg; + stan.poziomZoom = arg.level; + return undefined; + }, + panBy: function (arg) { + stan.panBy = arg; + }, + fit: function () { + stan.fitWolane = true; + } + }; +} + +// Zdarzenie klawiatury jako zwykly obiekt — obsluzKlawisz nie wywoluje na +// nim nic poza odczytem pol, wiec nie potrzeba prawdziwego KeyboardEvent. +function zdarzenie(key, modyfikatory) { + modyfikatory = modyfikatory || {}; + return { + key: key, + ctrlKey: modyfikatory.ctrlKey || false, + metaKey: modyfikatory.metaKey || false, + altKey: modyfikatory.altKey || false, + shiftKey: modyfikatory.shiftKey || false + }; +} + +describe("obsluzKlawisz — strzalki", () => { + let cy; + beforeEach(() => { + cy = atrapaCy(); + }); + + it("ArrowUp przesuwa w gore i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowUp")); + expect(wynik).toBe(true); + expect(cy._stan.panBy.y).toBeGreaterThan(0); + expect(cy._stan.panBy.x).toBe(0); + }); + + it("ArrowDown przesuwa w dol i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowDown")); + expect(wynik).toBe(true); + expect(cy._stan.panBy.y).toBeLessThan(0); + expect(cy._stan.panBy.x).toBe(0); + }); + + it("ArrowLeft przesuwa w lewo i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowLeft")); + expect(wynik).toBe(true); + expect(cy._stan.panBy.x).toBeGreaterThan(0); + expect(cy._stan.panBy.y).toBe(0); + }); + + it("ArrowRight przesuwa w prawo i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowRight")); + expect(wynik).toBe(true); + expect(cy._stan.panBy.x).toBeLessThan(0); + expect(cy._stan.panBy.y).toBe(0); + }); +}); + +describe("obsluzKlawisz — zoom", () => { + let cy; + beforeEach(() => { + cy = atrapaCy({ zoom: 1 }); + }); + + it("'+' przyblizA (wspolczynnik > 1) i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("+")); + expect(wynik).toBe(true); + expect(cy._stan.zoomArg.level).toBeGreaterThan(1); + }); + + it("'=' przyblizA i zwraca true (bez Shift na wiekszosci ukladow)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("=")); + expect(wynik).toBe(true); + expect(cy._stan.zoomArg.level).toBeGreaterThan(1); + }); + + it("'-' oddala (wspolczynnik < 1) i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("-")); + expect(wynik).toBe(true); + expect(cy._stan.zoomArg.level).toBeLessThan(1); + }); + + it("'_' oddala i zwraca true", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("_")); + expect(wynik).toBe(true); + expect(cy._stan.zoomArg.level).toBeLessThan(1); + }); +}); + +describe("obsluzKlawisz — Home", () => { + it("Home dopasowuje graf (cy.fit()) i zwraca true", () => { + const cy = atrapaCy(); + const wynik = obsluzKlawisz(cy, zdarzenie("Home")); + expect(wynik).toBe(true); + expect(cy._stan.fitWolane).toBe(true); + }); +}); + +describe("obsluzKlawisz — klawisze nieobslugiwane (bez pulapki, 2.1.2)", () => { + let cy; + beforeEach(() => { + cy = atrapaCy(); + }); + + it.each(["Tab", "Escape", "Enter", "a"])( + "%s zwraca false i nic nie wola", + (klawisz) => { + const wynik = obsluzKlawisz(cy, zdarzenie(klawisz)); + expect(wynik).toBe(false); + expect(cy._stan.panBy).toBeNull(); + expect(cy._stan.zoomArg).toBeNull(); + expect(cy._stan.fitWolane).toBe(false); + } + ); +}); + +describe("obsluzKlawisz — skroty przegladarki nie sa przechwytywane (1.4.4)", () => { + let cy; + beforeEach(() => { + cy = atrapaCy(); + }); + + it("Ctrl+ArrowUp zwraca false i nic nie wola (skrot przegladarki)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowUp", { ctrlKey: true })); + expect(wynik).toBe(false); + expect(cy._stan.panBy).toBeNull(); + }); + + it("Cmd(meta)+'+' zwraca false (Cmd+Plus to zoom przegladarki)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("+", { metaKey: true })); + expect(wynik).toBe(false); + expect(cy._stan.zoomArg).toBeNull(); + }); + + it("Ctrl+Home zwraca false (przewijanie strony na gore)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("Home", { ctrlKey: true })); + expect(wynik).toBe(false); + expect(cy._stan.fitWolane).toBe(false); + }); + + it("Alt+ArrowLeft zwraca false (nawigacja wstecz przegladarki)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("ArrowLeft", { altKey: true })); + expect(wynik).toBe(false); + expect(cy._stan.panBy).toBeNull(); + }); + + it("'+' z Shift zwraca true i przyblizA (Shift potrzebny do wpisania +)", () => { + const wynik = obsluzKlawisz(cy, zdarzenie("+", { shiftKey: true })); + expect(wynik).toBe(true); + expect(cy._stan.zoomArg.level).toBeGreaterThan(1); + }); +}); diff --git a/tests/js/nawigacja-grafu.test.js b/tests/js/nawigacja-grafu.test.js new file mode 100644 index 000000000..2ec8ca3cf --- /dev/null +++ b/tests/js/nawigacja-grafu.test.js @@ -0,0 +1,140 @@ +// Nawigacja po grafie powiazan bez przeciagania (WCAG 2.5.7) i z klawiatury +// (2.1.1). Modul operuje na instancji Cytoscape przez jej publiczne API, +// wiec testujemy go na atrapie — bez uruchamiania biblioteki. + +import { describe, it, expect, beforeEach } from "vitest"; +import { + przesun, + zoomuj, + dopasuj +} from "../../src/powiazania_autorow/static/powiazania_autorow/js/powiazania/nawigacja.js"; + +function atrapaCy(opcje) { + opcje = opcje || {}; + const stan = { + panBy: null, + zoomArg: null, + fitWolane: false, + poziomZoom: opcje.zoom === undefined ? 1 : opcje.zoom + }; + return { + _stan: stan, + width: () => (opcje.width === undefined ? 1000 : opcje.width), + height: () => (opcje.height === undefined ? 500 : opcje.height), + minZoom: () => 0.1, + maxZoom: () => 4, + zoom: function (arg) { + if (arg === undefined) { + return stan.poziomZoom; + } + stan.zoomArg = arg; + stan.poziomZoom = arg.level; + return undefined; + }, + panBy: function (arg) { + stan.panBy = arg; + }, + fit: function () { + stan.fitWolane = true; + } + }; +} + +describe("przesun", () => { + let cy; + beforeEach(() => { + cy = atrapaCy(); + }); + + it("w prawo przesuwa widok w lewo (ujemny x)", () => { + przesun(cy, "prawo"); + expect(cy._stan.panBy.x).toBeLessThan(0); + expect(cy._stan.panBy.y).toBe(0); + }); + + it("w lewo przesuwa widok w prawo (dodatni x)", () => { + przesun(cy, "lewo"); + expect(cy._stan.panBy.x).toBeGreaterThan(0); + expect(cy._stan.panBy.y).toBe(0); + }); + + it("w dol przesuwa widok w gore (ujemny y)", () => { + przesun(cy, "dol"); + expect(cy._stan.panBy.y).toBeLessThan(0); + expect(cy._stan.panBy.x).toBe(0); + }); + + it("w gore przesuwa widok w dol (dodatni y)", () => { + przesun(cy, "gora"); + expect(cy._stan.panBy.y).toBeGreaterThan(0); + expect(cy._stan.panBy.x).toBe(0); + }); + + it("krok skaluje sie z szerokoscia widoku", () => { + const waski = atrapaCy({ width: 500 }); + const szeroki = atrapaCy({ width: 2000 }); + przesun(waski, "prawo"); + przesun(szeroki, "prawo"); + expect(Math.abs(szeroki._stan.panBy.x)).toBeGreaterThan( + Math.abs(waski._stan.panBy.x) + ); + }); + + it("krok skaluje sie z wysokoscia widoku", () => { + // Osobny test dla osi Y, bo `przesun` liczy dy z `cy.height()`, a nie + // z `cy.width()`: pomylka w tej jednej literze przeszlaby test wyzej + // (tam wysokosc jest stala), a pionowy krok skalowalby sie szerokoscia. + const niski = atrapaCy({ height: 250 }); + const wysoki = atrapaCy({ height: 1000 }); + przesun(niski, "gora"); + przesun(wysoki, "gora"); + expect(Math.abs(wysoki._stan.panBy.y)).toBeGreaterThan( + Math.abs(niski._stan.panBy.y) + ); + }); + + it("nieznany kierunek nic nie robi", () => { + przesun(cy, "wszedzie"); + expect(cy._stan.panBy).toBeNull(); + }); +}); + +describe("zoomuj", () => { + it("przyblizanie zwieksza poziom", () => { + const cy = atrapaCy({ zoom: 1 }); + zoomuj(cy, 1.2); + expect(cy._stan.zoomArg.level).toBeCloseTo(1.2); + }); + + it("oddalanie zmniejsza poziom", () => { + const cy = atrapaCy({ zoom: 1 }); + zoomuj(cy, 1 / 1.2); + expect(cy._stan.zoomArg.level).toBeLessThan(1); + }); + + it("nie przekracza maxZoom", () => { + const cy = atrapaCy({ zoom: 3.9 }); + zoomuj(cy, 1.2); + expect(cy._stan.zoomArg.level).toBe(4); + }); + + it("nie schodzi ponizej minZoom", () => { + const cy = atrapaCy({ zoom: 0.11 }); + zoomuj(cy, 1 / 1.2); + expect(cy._stan.zoomArg.level).toBe(0.1); + }); + + it("zoomuje wokol srodka widoku", () => { + const cy = atrapaCy({ width: 1000, height: 500 }); + zoomuj(cy, 1.2); + expect(cy._stan.zoomArg.renderedPosition).toEqual({ x: 500, y: 250 }); + }); +}); + +describe("dopasuj", () => { + it("wola cy.fit()", () => { + const cy = atrapaCy(); + dopasuj(cy); + expect(cy._stan.fitWolane).toBe(true); + }); +}); diff --git a/tests/js/podepnij-nawigacje.test.js b/tests/js/podepnij-nawigacje.test.js new file mode 100644 index 000000000..072c804ea --- /dev/null +++ b/tests/js/podepnij-nawigacje.test.js @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +// +// Spoiwo miedzy DOM-em a nawigacja po grafie: ktory przycisk wywoluje ktora +// akcje i GDZIE wisi handler klawiatury. +// +// Ten plik powstal po przegladzie mutacyjnym, ktory pokazal, ze sama logika +// (`przesun`, `zoomuj`, `obsluzKlawisz`) byla przetestowana porzadnie, ale +// spoiwo nie bylo testowane WCALE. Cztery mutacje przechodzily wtedy cala +// suite na zielono: +// * zamiana "gora" z "dol" w mapie kierunkow, +// * wszystkie cztery strzalki robiace to samo, +// * odpiety handler zoom-out albo "dopasuj", +// * podpiecie keydown do `document` zamiast do kontenera grafu, +// * usuniety `preventDefault`. +// Ostatnia jest najgrozniejsza: `+` i `-` to znaki drukowalne, wiec podlegaja +// 2.1.4, a spelniamy je trzecim wariantem kryterium ("aktywne wylacznie przy +// focusie komponentu"). Handler na `document` zamienia je w globalne skroty +// jednoznakowe — czyli lamie dokladnie to, co ta galaz naprawia. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + KIERUNKI_PRZYCISKOW, + podepnijNawigacje +} from "../../src/powiazania_autorow/static/powiazania_autorow/js/powiazania/nawigacja.js"; + +const PRZYCISKI = [ + "graf-nav-gora", + "graf-nav-dol", + "graf-nav-lewo", + "graf-nav-prawo", + "graf-nav-zoom-in", + "graf-nav-zoom-out", + "graf-nav-dopasuj" +]; + +function atrapaCy() { + const stan = { panBy: [], zoom: [], fit: 0 }; + return { + _stan: stan, + width: () => 1000, + height: () => 500, + zoom: (arg) => { + if (arg === undefined) { + return 1; + } + stan.zoom.push(arg); + return undefined; + }, + minZoom: () => 0.1, + maxZoom: () => 4, + panBy: (arg) => stan.panBy.push(arg), + fit: () => (stan.fit += 1) + }; +} + +function zbudujDom() { + document.body.innerHTML = + '
' + + PRZYCISKI.map((id) => ``).join(""); + return document; +} + +describe("podepnijNawigacje — mapowanie przyciskow", () => { + let cy; + + beforeEach(() => { + cy = atrapaCy(); + podepnijNawigacje(cy, zbudujDom()); + }); + + // Znaki `panBy` sa odwrocone wzgledem intuicji: przesuwaja PLOTNO, a + // przycisk opisuje ruch WIDOKU. "w gore" => plotno w dol => dodatni y. + it.each([ + ["graf-nav-gora", "y", 1], + ["graf-nav-dol", "y", -1], + ["graf-nav-lewo", "x", 1], + ["graf-nav-prawo", "x", -1] + ])("%s przesuwa wlasciwa os we wlasciwa strone", (id, os, znak) => { + document.getElementById(id).click(); + + expect(cy._stan.panBy).toHaveLength(1); + const ruch = cy._stan.panBy[0]; + expect(Math.sign(ruch[os])).toBe(znak); + // druga os musi zostac nietknieta — inaczej "w gore" moglby + // jednoczesnie jechac w bok + expect(ruch[os === "x" ? "y" : "x"]).toBe(0); + }); + + it("kazdy kierunek daje INNY ruch niz pozostale", () => { + // Lapie mutacje "wszystkie strzalki robia to samo", ktorej testy + // per-przycisk osobno by nie zlapaly, gdyby akurat trafily w ten + // sam kierunek. + Object.keys(KIERUNKI_PRZYCISKOW).forEach((id) => + document.getElementById(id).click() + ); + + const podpisy = cy._stan.panBy.map((r) => `${r.x}|${r.y}`); + expect(new Set(podpisy).size).toBe(4); + }); + + it("przyblizanie zwieksza, oddalanie zmniejsza", () => { + document.getElementById("graf-nav-zoom-in").click(); + document.getElementById("graf-nav-zoom-out").click(); + + expect(cy._stan.zoom).toHaveLength(2); + expect(cy._stan.zoom[0].level).toBeGreaterThan(1); + expect(cy._stan.zoom[1].level).toBeLessThan(1); + }); + + it("dopasuj wola cy.fit()", () => { + // Jedyna akcja RATUNKOWA: uzytkownik klawiatury, ktory wyjechal + // widokiem poza graf, bez niej nie ma powrotu. + document.getElementById("graf-nav-dopasuj").click(); + + expect(cy._stan.fit).toBe(1); + }); + + it("kazdy z siedmiu przyciskow cos robi", () => { + // Strazniik przed odpieciem dowolnego handlera. Test szablonowy + // potwierdza tylko, ze przycisk ISTNIEJE w HTML-u, a martwy przycisk + // jest gorszy niz jego brak: audyt widzi kontrolke, uzytkownik nie + // dostaje funkcji (2.5.7 formalnie niespelnione). + PRZYCISKI.forEach((id) => { + const przed = cy._stan.panBy.length + cy._stan.zoom.length + cy._stan.fit; + document.getElementById(id).click(); + const po = cy._stan.panBy.length + cy._stan.zoom.length + cy._stan.fit; + + expect(po, `przycisk ${id} nie robi nic`).toBeGreaterThan(przed); + }); + }); +}); + +describe("podepnijNawigacje — gdzie wisi klawiatura (2.1.4)", () => { + let cy; + let kontener; + + beforeEach(() => { + cy = atrapaCy(); + kontener = podepnijNawigacje(cy, zbudujDom()); + }); + + it("handler wisi na kontenerze grafu, nie na document", () => { + expect(kontener).toBe(document.getElementById("cytoscape-container")); + }); + + it("strzalka w kontenerze przesuwa widok", () => { + kontener.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) + ); + + expect(cy._stan.panBy).toHaveLength(1); + }); + + it("`+` POZA grafem nie zoomuje", () => { + // Sedno zgodnosci z 2.1.4 dla `+`/`-`. Gdyby handler wisial na + // `document`, to zdarzenie by go uruchomilo i skrot jednoznakowy + // dzialalby globalnie. + const obcy = document.createElement("input"); + document.body.appendChild(obcy); + + obcy.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "+", bubbles: true }) + ); + + expect(cy._stan.zoom).toHaveLength(0); + }); + + it("obsluzony klawisz dostaje preventDefault, nieobsluzony nie", () => { + // Bez preventDefault strzalka jednoczesnie przesuwa graf i przewija + // strone. Z preventDefault na WSZYSTKIM — Tab przestaje wyprowadzac + // focus i robi sie pulapka klawiaturowa (2.1.2). + const strzalka = new window.KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true + }); + const tab = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true + }); + + kontener.dispatchEvent(strzalka); + kontener.dispatchEvent(tab); + + expect(strzalka.defaultPrevented).toBe(true); + expect(tab.defaultPrevented).toBe(false); + }); + + it("brak przyciskow w DOM nie wywala podpinania", () => { + document.body.innerHTML = ""; + const cy2 = atrapaCy(); + + expect(() => podepnijNawigacje(cy2, document)).not.toThrow(); + }); +}); diff --git a/tests/js/skroty-klawiszowe.test.js b/tests/js/skroty-klawiszowe.test.js new file mode 100644 index 000000000..5183ddf3a --- /dev/null +++ b/tests/js/skroty-klawiszowe.test.js @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +// +// Preferencja skrotow jednoznakowych (WCAG 2.1.4). Kryterium wymaga, zeby +// uzytkownik mogl skrot wylaczyc — BPP w czesci publicznej nie ma kont, wiec +// preferencja siedzi w localStorage. Domyslka to "wlaczone": brak wpisu nie +// moze zmieniac zachowania nikomu, kto nic nie ustawil. + +import { describe, it, expect, beforeEach } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +const ZRODLO = readFileSync( + resolve(__dirname, "../../src/bpp/static/bpp/js/skroty-klawiszowe.js"), + "utf-8" +); + +// Podmienia CALY obiekt `localStorage` na atrape z podanymi nadpisaniami +// i zwraca funkcje przywracajaca oryginal. +// +// NIE wolno tu robic `window.localStorage.setItem = () => {...}`. W jsdom +// `Storage` jest legacy platform object: przypisanie nieznanej wlasciwosci +// nie przeslania metody, tylko ZAPISUJE POZYCJE o kluczu "setItem". +// Prawdziwy `setItem` wykonuje sie wtedy dalej, a test przestaje sprawdzac +// to, co deklaruje. Zachowanie zalezy od wersji jsdom: lokalnie testy +// przechodzily, a na CI padl ten jeden, ktory FAKTYCZNIE potrzebowal +// dzialajacego stubu (`expected '0' to be null`). Pozostale trzy +// przechodzily niezaleznie od tego, czy stub zadzialal — czyli nie +// pilnowaly niczego. +function podmienStorage(nadpisania) { + const oryginalny = window.localStorage; + const atrapa = { + getItem: (k) => oryginalny.getItem(k), + setItem: (k, v) => oryginalny.setItem(k, v), + removeItem: (k) => oryginalny.removeItem(k), + clear: () => oryginalny.clear(), + ...nadpisania, + }; + Object.defineProperty(window, "localStorage", { + value: atrapa, + configurable: true, + writable: true, + }); + return function przywroc() { + Object.defineProperty(window, "localStorage", { + value: oryginalny, + configurable: true, + writable: true, + }); + }; +} + +const RZUCAJACY_ZAPIS = { + setItem: () => { + throw new Error("QuotaExceededError"); + }, +}; + +function zaladuj() { + // Modul jest skryptem window-globalnym (IIFE), nie modulem ESM — + // wykonujemy go na biezacym window jsdom. + new Function(ZRODLO).call(window); + return window; +} + +beforeEach(() => { + // Polyfill localStorage jeśli nie jest dostępny (jsdom 25+) + if (!window.localStorage) { + const store = {}; + window.localStorage = { + getItem: (key) => store[key] || null, + setItem: (key, value) => { + store[key] = String(value); + }, + removeItem: (key) => { + delete store[key]; + }, + clear: () => { + Object.keys(store).forEach((key) => delete store[key]); + }, + key: (index) => Object.keys(store)[index] || null, + get length() { + return Object.keys(store).length; + }, + }; + } + window.localStorage.clear(); + delete window.bppSkrotyWlaczone; + delete window.bppUstawSkroty; + delete window.bppPodepnijPrzelacznikSkrotow; +}); + +describe("bppSkrotyWlaczone", () => { + it("domyslnie wlaczone gdy brak wpisu", () => { + expect(zaladuj().bppSkrotyWlaczone()).toBe(true); + }); + + it('"0" wylacza', () => { + window.localStorage.setItem("bpp.skrotyJednoznakowe", "0"); + expect(zaladuj().bppSkrotyWlaczone()).toBe(false); + }); + + it('"1" wlacza', () => { + window.localStorage.setItem("bpp.skrotyJednoznakowe", "1"); + expect(zaladuj().bppSkrotyWlaczone()).toBe(true); + }); + + it("smiec w localStorage traktowany jak brak wpisu", () => { + window.localStorage.setItem("bpp.skrotyJednoznakowe", "tak"); + expect(zaladuj().bppSkrotyWlaczone()).toBe(true); + }); + + it("nie rzuca gdy localStorage niedostepny", () => { + const przywroc = podmienStorage({ + getItem: () => { + throw new Error("SecurityError"); + }, + }); + try { + const w = zaladuj(); + expect(w.bppSkrotyWlaczone()).toBe(true); + } finally { + przywroc(); + } + }); +}); + +describe("bppUstawSkroty", () => { + it("zapisuje wylaczenie i zwraca nowy stan", () => { + const w = zaladuj(); + expect(w.bppUstawSkroty(false)).toBe(false); + expect(window.localStorage.getItem("bpp.skrotyJednoznakowe")).toBe("0"); + expect(w.bppSkrotyWlaczone()).toBe(false); + }); + + it("zapisuje wlaczenie", () => { + const w = zaladuj(); + w.bppUstawSkroty(false); + expect(w.bppUstawSkroty(true)).toBe(true); + expect(window.localStorage.getItem("bpp.skrotyJednoznakowe")).toBe("1"); + }); + + it("nie rzuca gdy zapis niemozliwy", () => { + const przywroc = podmienStorage(RZUCAJACY_ZAPIS); + try { + const w = zaladuj(); + expect(() => w.bppUstawSkroty(false)).not.toThrow(); + } finally { + przywroc(); + } + }); + + it("fallback: gdy setItem rzuca, stan trzyma się w sesji (nie w localStorage)", () => { + const przywroc = podmienStorage(RZUCAJACY_ZAPIS); + try { + const w = zaladuj(); + w.bppUstawSkroty(false); + // localStorage jest puste (zapis się nie powiódł) + expect(window.localStorage.getItem("bpp.skrotyJednoznakowe")).toBe( + null + ); + // ale bppSkrotyWlaczone() zwraca false (z fallback-u w pamięci) + expect(w.bppSkrotyWlaczone()).toBe(false); + } finally { + przywroc(); + } + }); + + it("fallback: przełącznik działa w sesji nawet bez localStorage", () => { + const przywroc = podmienStorage(RZUCAJACY_ZAPIS); + try { + const w = zaladuj(); + const el = window.document.createElement("button"); + window.document.body.appendChild(el); + w.bppPodepnijPrzelacznikSkrotow(el); + // Initialnie włączone + expect(el.getAttribute("aria-pressed")).toBe("true"); + // Klik wyłącza + el.click(); + expect(el.getAttribute("aria-pressed")).toBe("false"); + expect(el.textContent).toContain("wyłączone"); + // Drugi klik włącza + el.click(); + expect(el.getAttribute("aria-pressed")).toBe("true"); + expect(el.textContent).toContain("włączone"); + } finally { + przywroc(); + } + }); +}); + +describe("bppPodepnijPrzelacznikSkrotow", () => { + function przycisk() { + const el = window.document.createElement("button"); + window.document.body.appendChild(el); + return el; + } + + it("ustawia stan poczatkowy na wlaczony", () => { + const w = zaladuj(); + const el = przycisk(); + w.bppPodepnijPrzelacznikSkrotow(el); + expect(el.getAttribute("aria-pressed")).toBe("true"); + expect(el.textContent).toContain("włączone"); + }); + + it("klik wylacza skroty i aktualizuje etykiete", () => { + const w = zaladuj(); + const el = przycisk(); + w.bppPodepnijPrzelacznikSkrotow(el); + el.click(); + expect(w.bppSkrotyWlaczone()).toBe(false); + expect(el.getAttribute("aria-pressed")).toBe("false"); + expect(el.textContent).toContain("wyłączone"); + }); + + it("drugi klik wraca do stanu wyjsciowego", () => { + const w = zaladuj(); + const el = przycisk(); + w.bppPodepnijPrzelacznikSkrotow(el); + el.click(); + el.click(); + expect(w.bppSkrotyWlaczone()).toBe(true); + expect(el.getAttribute("aria-pressed")).toBe("true"); + }); + + it("odzwierciedla stan zapisany wczesniej", () => { + window.localStorage.setItem("bpp.skrotyJednoznakowe", "0"); + const w = zaladuj(); + const el = przycisk(); + w.bppPodepnijPrzelacznikSkrotow(el); + expect(el.getAttribute("aria-pressed")).toBe("false"); + }); +});