# Raport: szukaj zawodnika na stronie kolejki

Gałąź: `szukaj-zawodnika` (nie scalona do `main`). Zadanie wyjściowe: na
`/gw/{numer}` pole wyszukiwania nad listą meczów — właściciel wpisuje
fragment nazwiska (np. `Wirtz`), strona podświetla **całe wiersze meczów**,
w których zawodnik występuje po którejkolwiek stronie, i dodatkowo pokazuje w
wierszu, po której stronie i czy w składzie startowym czy na ławce. Pole
tylko dla właściciela (`AccessGate::allows('settings')`), sprawdzane też w
kontrolerze — nie tylko ukryte w formularzu.

## Zmiany plik po pliku

### `src/Repository/ElementRepository.php`
Nowa metoda `findByNameFragment(string $fragment): array` — jedno zapytanie
DQL, `LOWER()` po `webName`/`firstName`/`secondName`, dopasowanie `LIKE
%fragment%`. Pusty/białoznakowy fragment zwraca `[]` bez odpytywania bazy.

### `src/Entity/Element.php`
Dodane gettery `getFirstName()`/`getSecondName()` — pola już istniały, ale
nie miały publicznego dostępu; potrzebne, żeby komunikat "Znaleziono: Imię
Nazwisko (pseudonim)" mógł je wyświetlić w Twigu. Bez zmian w schemacie bazy.

### `src/League/PlayerRosterStatus.php` (nowy plik)
Enum `Starting`/`Bench` — status znalezionego zawodnika po jednej stronie
meczu.

### `src/League/PlayerMatchHighlight.php` (nowy plik)
Wartość `{mine: ?PlayerRosterStatus, opponent: ?PlayerRosterStatus}` plus
`isMatched(): bool`. Statyczna fabryka `forView(MatchView $view, array
$elementIds)` przechodzi po `entryIds()` obu drużyn (`fixture->getMySquad()`/
`getOpponentSquad()`), dla każdego wpisu bierze `$view->snapshotFor($entryId)`
i jego `getPicks()` — **zero nowych zapytań**: `MatchViewBuilder::build()` już
wcześniej woła `DifferentialAnalyzer::analyze()`, który dla każdego snapshotu
z `hasData() === true` sam odczytuje `getPicks()`, więc kolekcja jest już w
pamięci (Doctrine identity map) w chwili, gdy sięga po nią `forView()`.
Snapshoty bez danych (`hasData() === false`) są pomijane celowo — inaczej
`getPicks()` na nietkniętej kolekcji wymusiłoby doczytanie z bazy. Skład
startowy (mnożnik > 0) wygrywa natychmiast nad ławką (mnożnik 0), gdy ten sam
zawodnik występuje w kilku wpisach jednej strony (format 4x4).

### `src/Controller/GameweekController.php`
`show()` dostaje `AccessGate $accessGate` i `ElementRepository $elements`.
Dostęp: `$canSearchPlayers = $accessGate->allows('settings')` — dokładnie ten
sam mechanizm co ukrywanie pozycji menu, żaden nowy. Parametr `zawodnik`
czytany **tylko** gdy `$canSearchPlayers` jest prawdą; w przeciwnym razie
zmienna `$playerSearchTerm` zostaje `null`, niezależnie od tego, co ktoś wpisał
w adres — to jest właściwe miejsce egzekwowania reguły z polecenia ("parametr
ma być zignorowany, nie tylko ukryty"). Gdy wyszukiwanie jest aktywne, jedno
wywołanie `findByNameFragment()` buduje `array<int,true> $matchedElementIds`;
w pętli budującej `$views` (niezmienionej co do treści) doszła tylko jedna
linia: `PlayerMatchHighlight::forView($view, $matchedElementIds)`. Do widoku
trafiają: `canSearchPlayers`, `playerSearchTerm`, `playerMatches`, `highlights`.

### `templates/gameweek/show.html.twig`
Pod paskiem świeżości (`.freshness`), tylko gdy `canSearchPlayers`: formularz
GET bez akcji (`<form method="get">`, pole `name="zawodnik"` z zachowaną
wartością, przycisk „Szukaj"). Pod formularzem komunikat: „Znaleziono: ..."
(lista imię+nazwisko+pseudonim wszystkich trafionych) albo „Nie znaleziono
zawodnika pasującego do «...»", w zależności od `playerMatches`. W pętli po
`views` doszedł `{% set highlight = highlights[loop.index0] %}` i klasa wiersza
`match-list__row--hit`/`match-list__row--dim` (tylko gdy fragment faktycznie
do kogoś pasował — `searchActive = playerSearchTerm is not null and
playerMatches is not empty`; brak trafień = lista bez zmian, zgodnie z
poleceniem). Obie nazwy stron dostały włączenie nowego partiału z odznaką.

### `templates/gameweek/_player_hit_badge.html.twig` (nowy plik)
Partiał przyjmujący `status` (`PlayerRosterStatus|null`) — renderuje odznakę
z tekstem „W składzie"/„Ławka" i atrybutem `title` po polsku
(„Zawodnik w wyjściowym składzie" / „Zawodnik na ławce rezerwowych") albo nic,
gdy `status` jest `null`. Jedno miejsce logiki tekstu/title dla obu stron
wiersza (mine i opponent), żeby nie duplikować warunku w głównym szablonie.

### `public/css/app.css`
- Nagłówek pliku: dopisana zasada „każdy status rozróżniany kolorem musi być
  czytelny też bez rozpoznawania barwy" — tekst/kształt/`title`, nigdy sam
  odcień.
- Sekcja **wyszukiwanie zawodnika**: `.player-search` (układ pola+przycisku),
  `.player-search__input`, `.player-search__result` i modyfikator `--empty`.
- Przy liście meczów: `.match-list__row--hit` (obramowanie), `.match-list__row--dim`
  (przezroczystość — nie odcień, więc czytelne przy każdym typie
  daltonizmu), `.player-hit-badge` + modyfikatory `--starting` (wypełnione tło,
  ciągła ramka) / `--bench` (bez wypełnienia, ramka przerywana) — rozróżnienie
  kształtem i tekstem, kolor jest tylko dodatkiem.

### Testy
- `tests/Integration/ElementRepositoryTest.php` (nowy, 6 testów) —
  dopasowanie po `webName`/`firstName`/`secondName`, wielkość liter, wiele
  trafień, brak trafień, pusty fragment.
- `tests/Integration/PlayerMatchHighlightTest.php` (nowy, 7 testów) — starter,
  ławkowicz, priorytet startera nad ławkowiczem przy kilku wpisach jednej
  strony (format 4x4), brak listy zawodników, brak trafienia, snapshoty bez
  danych pomijane, obie strony trafione jednocześnie.
- `tests/Functional/GameweekControllerTest.php` (10 nowych testów) — formularz
  widoczny tylko dla właściciela; **parametr ignorowany bez kodu właściciela**
  (test krytyczny z polecenia); trafienie w skład startowy; trafienie w
  ławkowicza (inny tekst/klasa); kilka pasujących zawodników jednocześnie;
  brak trafień → komunikat zamiast cichej listy; brak parametru → lista
  neutralna; wartość pola zachowana po przeładowaniu; **dwa z sześciu wierszy
  wyróżnione, cztery przygaszone** (scenariusz dopisany po doprecyzowaniu
  właściciela).

## Dowód czerwonych testów

Przed implementacją kontrolera/szablonu (repozytorium i klasa domenowa były
już gotowe, testy dla nich przechodziły od razu — patrz niżej) uruchomienie
`vendor/bin/phpunit tests/Functional/GameweekControllerTest.php` kończyło się:

```
1) testSearchFormVisibleOnlyForOwner
   Wlasciciel musi widziec pole wyszukiwania.
   Failed asserting that actual size 0 matches expected size 1.

2) testHighlightsRowWhenPlayerIsInStartingLineup
   Wiersz z trafieniem musi byc wyrozniony.
   Failed asserting that actual size 0 matches expected size 1.

3) testHighlightsBenchedPlayerDifferentlyThanStarter — jak wyżej

4) testMultipleMatchesHighlightBothSidesAndListFoundNames — jak wyżej

5) testNoMatchesShowsExplicitMessageInsteadOfPlainList
   Failed asserting that '...Kuba FC -4,0 : -4,0 Rywale Remis' contains
   "Nie znaleziono".

+ 3 błędy (InvalidArgumentException: pusta lista węzłów dla
  input[name="zawodnik"], bo formularza jeszcze nie było w ogóle)

Tests: 22, Assertions: 57, Errors: 3, Failures: 5.
```

Dla `App\League\PlayerMatchHighlight` implementacja i test powstały razem
(logika jest zbyt prosta, żeby sensownie pisać ją na ślepo pod pusty plik),
więc czerwony stan zweryfikowano inaczej: świadomie zepsuto warunek
priorytetu startera nad ławkowiczem (`> 0` → `< 0`) i uruchomiono testy —
3 z 7 padły z dokładnie oczekiwanym mismatchem (`Starting` oczekiwane, `Bench`
otrzymane), po przywróceniu warunku wszystkie 7 wróciły do zielonego stanu.
Dla `ElementRepository::findByNameFragment` metoda istniała od razu (bez niej
plik się nie skompilowałby), więc czerwonego stanu nie pokazuję osobno — test
przeszedł przy pierwszym uruchomieniu i sprawdza rzeczywiste zapytanie SQL na
prawdziwej bazie testowej (MySQL), nie atrapę.

## Wynik testów

```
vendor/bin/phpunit
OK (346 tests, 1223 assertions)
```

Przyrost względem stanu wyjściowego (323 testy): +23 — 6 w
`ElementRepositoryTest`, 7 w `PlayerMatchHighlightTest`, 10 w
`GameweekControllerTest`. `php bin/console lint:twig` na obu zmienionych/nowych
szablonach — OK. `php -l` na wszystkich zmienionych/nowych plikach PHP — OK.
`cache:clear --env=test` — OK (żaden błąd w kontenerze DI po zmianie sygnatury
konstruktora `GameweekController::show()`).

## Zapytania do bazy dodane przez tę funkcję

- **Bez parametru `zawodnik`** (albo bez kodu właściciela): **0** dodatkowych
  zapytań — kod nawet nie wchodzi w blok szukający.
- **Z parametrem, jako właściciel**: dokładnie **1** dodatkowe zapytanie —
  `ElementRepository::findByNameFragment()`. Podświetlenie każdego meczu w
  pętli (`PlayerMatchHighlight::forView()`) czyta wyłącznie już wczytane w
  pamięci `EntryGameweek`/`EntryGameweekPick` (przez `MatchView::snapshotFor()`
  i `getPicks()`, które i tak inicjalizuje `DifferentialAnalyzer` wewnątrz
  `MatchViewBuilder::build()`) — zero zapytań w pętli po meczach, niezależnie
  od liczby meczów w kolejce.

## Czego nie zweryfikowano wzrokowo

Zgodnie z poleceniem nie użyto przeglądarki ani zrzutów ekranu. W
szczególności nie widziałem na oczy:

- rzeczywistego wyglądu formularza `.player-search` obok paska świeżości
  (odstępy, wyrównanie pola i przycisku) — polegam na tym, że pole i przycisk
  używają tych samych zmiennych (`--panel`, `--r-sm`, `.button`) co istniejące
  formularze (`fixture/new.html.twig`), więc wyglądają spójnie z resztą
  aplikacji, ale nie sprawdziłem tego okiem,
- faktycznego kontrastu `.match-list__row--dim` (`opacity: 0.45`) na ciemnym
  tle `--panel` — liczba dobrana przez analogię do innych przygaszeń w branży,
  nie zmierzona narzędziem do kontrastu,
- wyglądu odznaki `.player-hit-badge` (ramka ciągła vs przerywana,
  wypełnienie) w praktyce — sprawdziłem tylko przez asercje tekstowe w testach
  funkcjonalnych, nie przez podgląd,
- zachowania na wąskim viewport (formularz i odznaki w układzie mobilnym listy
  meczów, który już wcześniej się przełącza na inną siatkę) — brak
  przeglądarki, polegam na tym, że nowe elementy (`.player-search`, odznaka
  wewnątrz `.match-list__side`) nie ingerują w istniejącą siatkę mobilną
  zdefiniowaną w `@media (max-width: 767.98px)`.

## Uwaga poza zakresem

Nie ruszałem liczenia wyniku, synchronizacji, formularza dodawania meczu ani
strony pojedynczego meczu (`MatchController`/`match/_match.html.twig`) — jak
zastrzeżono w poleceniu. Wyszukiwanie działa wyłącznie na liście kolejki
(`/gw/{numer}`); strona pojedynczego meczu nie pokazuje żadnego podświetlenia,
bo nie było to przedmiotem zlecenia.
