# Raport: perspektywa oglądającego i podgląd wyłącznie dla właściciela

Gałąź: `perspektywa` (nie scalona do `main`). Dwie sprawy:

1. Strona meczu (`fixture/mySquad`/`opponentSquad`, rozstrzygnięcie, kolory,
   różnice, pojedynki w trójkach, podgląd) liczyła się zawsze z perspektywy
   autora meczu, nie oglądającego. Naprawa dzieje się u źródła — przy
   budowaniu widoku, zanim cokolwiek zostanie policzone — nie w napisach.
2. Podgląd przed startem kolejki (składy i chipy z poprzedniej kolejki) to
   przewaga informacyjna — zostaje wyłącznie dla właściciela, sprawdzane po
   stronie serwera, zanim dane w ogóle powstaną.

Zapisany wynik meczu (encje, punkty, `App\Entity\Duel`) nie zmienia się ani o
jotę — to wyłącznie zmiana w prezentacji.

## Zmiany plik po pliku

### Nowe pliki

**`src/League/OrientedDuel.php`**
Prosta wartość: pojedynek trójek (`slot`, `mine: FplEntry`, `opponent:
FplEntry`) z rolami ustawionymi względem oglądającego. W odróżnieniu od
`App\Entity\Duel`, która zawsze niesie strony z perspektywy autora.

**`src/League/DuelOrientation.php`**
Wspólna logika odwracania stron pojedynku trójek, używana zarówno przez
`MatchViewBuilder`, jak i `UpcomingMatchPreviewBuilder` (żeby obie ścieżki
odwracały się identycznie, bez duplikowania kodu):
- `pairs(list<Duel> $duels, bool $flip): list<DuelPair>` — pary do liczenia
  wyniku (`DuelsScorer`), z ewentualnie zamienionymi rolami.
- `oriented(list<Duel> $duels, bool $flip): list<OrientedDuel>` — te same
  pojedynki, gotowe do wyświetlenia (etykiety, różnice per pojedynek).

### Zmienione pliki źródłowe

**`src/Entity/LeagueFixture.php`**
- Nowa metoda `isViewedFromOpponentSide(?int $viewerEntryId): bool` — jedyny
  test decydujący, czy trzeba odwrócić strony: `true` tylko wtedy, gdy
  `$viewerEntryId` gra po stronie zapisanej jako `opponentSquad`. Brak
  tożsamości, drużyna spoza meczu albo drużyna już po stronie `mySquad` — bez
  odwracania.
- Usunięta metoda `duelPairs()`. Budowała pary zawsze z perspektywy autora
  (`myEntry`/`opponentEntry` wprost z encji `Duel`) — to był jeden z dwóch
  źródeł błędu (drugim było czytanie `fixture.mySquad`/`opponentSquad` wprost
  w szablonach). Po przeniesieniu tej logiki do `DuelOrientation` metoda była
  martwa i myląca (nazwa sugerowała "pary meczu", a nie "pary autora") —
  zostawienie jej byłoby zostawieniem tego samego zapalnika na przyszłość.

**`src/League/MatchView.php`**
Nowe pola `public Squad $mySquad` i `public Squad $opponentSquad` — strony
względem oglądającego, ustawione przez `MatchViewBuilder`. Nowe pole
`public array $duels` (`list<OrientedDuel>`) dla pojedynków trójek. Szablony
mają czytać strony stąd, nie z `fixture.mySquad`/`opponentSquad` wprost.

**`src/League/MatchViewBuilder.php`**
`build()` przyjmuje nowy, końcowy parametr `?int $viewerEntryId = null`
(pozycja na końcu, żeby nie zepsuć istniejących wywołań pozycyjnych z
`refresh`/`force`). Liczy `$flip = $fixture->isViewedFromOpponentSide(...)`,
wybiera oriented `$mySquad`/`$opponentSquad` i buduje z nich `ScoreInput`-y
(`MatchSide::Mine`/`Opponent`), różnice zbiorcze i pary pojedynków
(`DuelOrientation::pairs()`) — dopiero z tych, już poprawnie zorientowanych
danych liczy się wynik, więc rozstrzygnięcie, marża, wiersze tabeli i
pojedynki wychodzą poprawne same z siebie. `duelDifferentials()` liczy teraz
różnice per pojedynek z ról już odwróconych (`DuelOrientation::oriented()`).

**`src/League/UpcomingMatchPreview.php`**
Nowe pola konstruktora `public Squad $mySquad`, `public Squad $opponentSquad`
(przed `$snapshots`) i `public array $duels` (`list<OrientedDuel>`, na
końcu). `getMySquad()`/`getOpponentSquad()` (fasada dla `_picks.html.twig`)
zwracają teraz te pola zamiast delegować do `$fixture->getMySquad()`/
`getOpponentSquad()` — czyli też są już zorientowane względem oglądającego.

**`src/League/UpcomingMatchPreviewBuilder.php`**
`build()` przyjmuje `?int $viewerEntryId = null`, liczy `$flip` tym samym
sposobem co `MatchViewBuilder` i przekazuje oriented squady/duele do
`UpcomingMatchPreview` — łącznie z gałęzią "pierwsza kolejka sezonu" (brak
poprzedniej kolejki), żeby nawet ten krótki komunikat miał poprawne nazwy
stron.

**`src/League/PlayerMatchHighlight.php`**
`forView()` czyta teraz `$view->mySquad->entryIds()` /
`$view->opponentSquad->entryIds()` zamiast `$view->fixture->getMySquad()`/
`getOpponentSquad()` — wyróżnienie w liście kolejki (kto ma szukanego
zawodnika) liczy się teraz po stronie oglądającego, zgodnie z etykietą
"moja"/"ich" pokazaną obok.

**`src/Controller/MatchController.php`**
- Oba wywołania `$builder->build(...)` dostają `viewerEntryId: $viewerEntryId`
  (identyczne z tym, które już liczy widoczność przez udział).
- Nowy parametr akcji `AccessGate $accessGate`.
- Podgląd: `$preview = (!$view->hasAnyData() && $accessGate->isAdmin()) ?
  $previewBuilder->build($fixture, viewerEntryId: $viewerEntryId) : null;`
  — sprawdzenie stoi przed wywołaniem buildera, więc dla niewłaściciela
  `UpcomingMatchPreviewBuilder` w ogóle się nie uruchamia: żadnych zapytań do
  FPL o poprzednią kolejkę, żadnych danych w zmiennych przekazanych do
  szablonu.

**`src/Controller/GameweekController.php`**
Oba wywołania `$builder->build($fixture, ...)` w pętli kolejki (odświeżanie i
budowa widoków) dostają `viewerEntryId: $viewerEntryId` — lista kolejki miała
ten sam błąd co strona meczu (nazwy stron i rozstrzygnięcie w wierszu).

### Zmienione szablony

**`templates/fixture/_match.html.twig`**
`fixture.mySquad.name`/`opponentSquad.name` → `view.mySquad.name`/
`view.opponentSquad.name` (nagłówek wyniku i etykiety różnic zbiorczych).
Pętla pojedynków: `fixture.duels` → `view.duels`, `duel.myEntry`/
`opponentEntry` → `duel.mine`/`duel.opponent` (oriented). Include
`_picks.html.twig` dostaje jawnie `mySquad`/`opponentSquad` z `view`. Blok
podglądu włącza się teraz tylko `{% elseif preview is not null %}` — gdy
`preview` jest `null` (niewłaściciel przed startem kolejki), nie renderuje
się nic ponad już istniejący badge "kolejka jeszcze się nie rozpoczęła" w
panelu wyniku.

**`templates/fixture/_picks.html.twig`**
Pętla stron czyta teraz z nowych parametrów `mySquad`/`opponentSquad`
zamiast `fixture.mySquad`/`fixture.opponentSquad` — `fixture` zostaje tylko
dla `fixture.format.squadSize` (układ kolumn, niezależny od strony).

**`templates/fixture/_upcoming_preview.html.twig`**
Podsumowanie chipów i pętla pojedynków czytają z `preview.mySquad`/
`opponentSquad`/`duels` zamiast z `fixture.*`. Include `_picks.html.twig`
dostaje `mySquad`/`opponentSquad` z `preview`.

**`templates/gameweek/show.html.twig`**
Wiersz listy: `fixture.mySquad.name`/`opponentSquad.name` →
`view.mySquad.name`/`view.opponentSquad.name` — nazwa, kolor (bursztyn/
błękit przez klasy `match-list__side--mine`/`--opponent`) i rozstrzygnięcie
(`view.result.outcome.label`, już liczone z oriented danych) zgadzają się
teraz ze sobą.

Bez zmian (poprawne już wcześniej, bo liczą się z `MatchResult`/`ScoreInput`,
które teraz same są oriented): `templates/fixture/_standings.html.twig`
(klasy `side--mine`/`side--opponent` z `row.input.side`), `templates/fixture/
_duels.html.twig` (`result.duels[].mine`/`.opponent`), `templates/fixture/
_differentials.html.twig` (czyta tylko przekazane `report`/`mineLabel`/
`opponentLabel`).

## Dowód czerwonych testów

Przed implementacją uruchomiłem sam nowo dopisany zestaw testów (18 metod
opisanych niżej) na niezmienionym jeszcze kodzie źródłowym:

```
vendor/bin/phpunit --filter 'testViewerFromMySquadDoesNotFlip|testViewerFromOpponentSquadFlips|
testViewerNotInTheMatchDoesNotFlip|testMissingIdentityDoesNotFlip|
testViewerFromOpponentSideSeesFlippedOutcomeAndSides|testViewerFromMySquadSideKeepsAuthorPerspective|
testViewerNotPlayingInTheMatchKeepsAuthorPerspective|testTrioDuelsFlipForViewerOnOpponentSide|
testTrioDuelDifferentialsFlipForViewerOnOpponentSide|testViewerFromOpponentSideSeesFlippedSquads|
testDuelsListFlipsForViewerOnOpponentSide|testHighlightFlipsForViewerOnOpponentSide|
testRowShowsPerspectiveOfTheViewerNotTheAuthor|testViewerOnOpponentSideSeesOwnOutcomeNotTheAuthors|
testAuthorStillSeesOwnPerspectiveAfterTheFix|testUpcomingPreviewIsHiddenFromNonAdminViewers|
testUpcomingPreviewIsStillShownToAdmin'
```

Wynik: `Tests: 18, Assertions: 22, Errors: 13, Failures: 3` — wszystkie 18
testów padło (część błędem, bo `build()` nie znał jeszcze parametru
`viewerEntryId`/`isViewedFromOpponentSide()` nie istniało; część
niezgodnością asercji, np. `.hint` zawierał "Porażka" zamiast "Zwycięstwo").
Po implementacji ten sam zestaw i cała reszta suity przechodzą.

## Które istniejące testy trzeba było przepisać i dlaczego

Sześć testów w `tests/Functional/MatchControllerTest.php`, dotyczących
wyłącznie zawartości podglądu przed startem kolejki, opisywało jego treść
bez żadnego odblokowanego poziomu dostępu:

- `testShowsUpcomingPreviewFromPreviousCheckedGameweekWhenCurrentGameweekHasNoDataYet`
- `testFirstGameweekOfSeasonShowsShortMessageInsteadOfPreview`
- `testBenchBoostSquadShowsElevenStartersAndFourOnBenchInPreview`
- `testTripleCaptainSquadShowsDoubleMultiplierAtCaptainInPreview`
- `testDifferentialsInPreviewIgnoreBenchPlayerOfBenchBoostSquad`
- `testViewingPreviewDoesNotMutateStoredSnapshot`

Po Sprawie 2 opisywały nieprawdę: domyślna tożsamość w tych testach (101) nie
ma poziomu właściciela, więc podgląd nie może się już pokazać. Zamiast
osłabiać asercje, dopisałem w każdym z nich `$this->unlockAdminAccess()`
(nowy prywatny helper, analogiczny do `unlockAccess()` z
`GameweekControllerTest`, tylko dopisujący się do już istniejącej sesji, żeby
nie zgubić wybranej tożsamości) tuż przed żądaniem strony — testy nadal
sprawdzają dokładnie tę samą treść podglądu, tylko jawnie z perspektywy
właściciela, co teraz jest warunkiem koniecznym, żeby ta treść w ogóle
powstała. Żadna asercja nie została złagodzona ani usunięta.

`testDoesNotShowUpcomingPreviewWhenCurrentGameweekAlreadyHasData` nie
wymagał zmian — sprawdza nieobecność podglądu, gdy kolejka już ma dane, co
jest niezależne od poziomu dostępu.

## Nowe testy (18)

- `tests/Integration/LeagueFixtureTest.php` (+4) — `isViewedFromOpponentSide()`
  dla czterech przypadków: widz po stronie `mySquad`, po stronie
  `opponentSquad`, spoza meczu, brak tożsamości.
- `tests/Integration/MatchViewBuilderTest.php` (+5) — odwrócenie
  rozstrzygnięcia/nazw stron dla widza po stronie `opponentSquad`; brak
  odwrócenia dla widza po stronie `mySquad` i dla widza spoza meczu;
  odwrócenie pojedynków trójek (pary i rozstrzygnięcia); odwrócenie różnic
  per pojedynek.
- `tests/Integration/UpcomingMatchPreviewBuilderTest.php` (+3) — odwrócenie
  stron podglądu; brak odwrócenia dla widza po stronie `mySquad`; odwrócenie
  listy pojedynków trójek.
- `tests/Integration/PlayerMatchHighlightTest.php` (+1) — wyróżnienie w
  liście kolejki odwraca się razem z resztą widoku.
- `tests/Functional/GameweekControllerTest.php` (+1) — wiersz listy kolejki
  pokazuje oglądającego po stronie "moja", autora po stronie "ich".
- `tests/Functional/MatchControllerTest.php` (+4) — dokładne odtworzenie
  zgłoszonego błędu (mecz 6:31, oglądający po stronie `opponentSquad` widzi
  "Zwycięstwo", autor nadal widzi "Porażka"); podgląd niewidoczny i
  nieobecny w źródle strony dla niewłaściciela (z atrapą HTTP, która zawala
  test, gdyby padło zapytanie o poprzednią kolejkę); podgląd nadal widoczny
  dla właściciela.

## Wynik suity

```
vendor/bin/phpunit
...
OK (389 tests, 1366 assertions)
```

389 = 371 istniejących + 18 nowych, wszystkie zielone. Żadna asercja nie
została osłabiona — sześć przepisanych testów sprawdza dokładnie to, co
wcześniej, tylko jawnie z perspektywy właściciela.

## Czego nie zweryfikowałem wzrokowo

Zgodnie z ograniczeniem projektu nie uruchamiałem przeglądarki ani nie
robiłem zrzutów ekranu. W szczególności nie sprawdziłem wizualnie:
- rzeczywistego wyglądu kolorów bursztyn/błękit po zamianie stron w wierszu
  listy kolejki i w panelu wyniku (opieram się na tym, że klasy CSS
  `side--mine`/`side--opponent` i `match-list__side--mine`/`--opponent` nie
  zmieniły nazw ani reguł stylu — zmieniła się wyłącznie zawartość, którą
  szablon do nich przypisuje);
- układu paska podziału (`spine__mine`/`spine__opponent`) na szerokim i
  wąskim ekranie;
- czy podgląd faktycznie znika z ekranu (a nie tylko z asercji) dla
  niewłaściciela — zweryfikowałem to przez `assertCount(0, ...)` na
  selektorze `.upcoming-preview` i przez brak stringa w treści odpowiedzi,
  co pokrywa "nie ma tego w źródle strony", ale nie samo renderowanie w
  przeglądarce.

Nie ruszałem liczenia wyniku (`MatchScorer`, `DuelsScorer`, `SumScorer`,
`LeagueScorer`), synchronizacji z FPL ani reguł widoczności meczów
(`LeagueFixtureRepository`) — poza dodaniem `viewerEntryId` do wywołań
`MatchViewBuilder::build()`/`UpcomingMatchPreviewBuilder::build()` nic w
kontrolerach się nie zmieniło poza opisanym zakresem.
