diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..c595ac3f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,48 @@
+# Golden-Master-Tests direkt auf dem Runner (ohne Docker), siehe TESTING.md
+name: Tests
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ golden-master:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.0', '8.1', '8.2', '8.3', '8.4']
+ name: PHP ${{ matrix.php }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # faketime fixiert die Zeit (Kalender, "heute"-Markierungen) wie im Docker-Image
+ - name: faketime installieren
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends faketime
+
+ - name: PHP einrichten
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: gd
+ # wie das offizielle Docker-Image: keine php.ini, nur Standardwerte
+ ini-file: none
+ coverage: none
+ tools: composer:v2
+
+ - name: Composer-Cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.composer/cache
+ key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
+ restore-keys: composer-${{ matrix.php }}-
+
+ - name: Abhaengigkeiten installieren
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Tests
+ env:
+ GOLDEN_MODE: compare
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..2cc9f054
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+vendor/
+.original/
+.phpunit.result.cache
+lmo/vendor/
+coverage/
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 00000000..46e332f5
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,278 @@
+# Golden-Master-Tests (PHPUnit + Docker)
+
+Ziel: Das Verhalten des **unveränderten** Codes festhalten, bevor refaktoriert wird, und nach jedem
+Schritt prüfen, dass sich nichts geändert hat. Die Tests brauchen keinen Webserver und keinen
+Installer: Jede Anfrage läuft in einem eigenen PHP-Prozess gegen eine frische Kopie der App.
+
+**Stand:** Das Gerüst läuft in Docker. Die Snapshots sind am unveränderten Original aufgezeichnet,
+die Erweiterungen (v1 bis v16) sind unten in der Reihenfolge ihrer Entstehung beschrieben.
+
+## Was geprüft wird
+| Test | Prüft |
+|---|---|
+| `PagesTest` | HTML der öffentlichen Seiten: Ligenübersicht, Tabelle (alle Typen, Teil-Spieltage, Heim/Auswärts, Punktespalten), Ergebnisse, Spielplan, Kreuztabelle, Statistik, Fieberkurve, Kalender, Info, Sprachen |
+| `CalculationTest` | `lmo-calctable.php` (inkl. direktem Vergleich): Sortierschlüssel und alle Bilanzspalten je Liga, 5 Tabellentypen, mehrere Spieltage, Hin-/Rückrunden-Kombinationen, Admin-Ansicht |
+
+Die 9 mitgelieferten Ligen haben **keine einzige gespielte Partie**. Deshalb erzeugt
+`LeagueFactory` 8 Testligen mit festem Seed: normale Liga, Minuspunkte + direkter Vergleich,
+Kegelwertung, Strafpunkte/-tore ab Spieltag, Sonderwertung (n.V./i.E.), Handicap-Reihenfolge,
+Wertung am grünen Tisch, Platzmarkierungen (Meister, Champions League, Abstieg).
+
+## Ablauf
+
+```bash
+# 1. Unveränderten Originalstand bereitstellen (ZIP von GitHub, NICHT den refaktorierten Stand)
+unzip lmo_php8-master.zip -d .original # ergibt .original/lmo_php8-master/lmo/...
+
+# 2. Snapshots am Original aufzeichnen (einmalig, danach ins Git einchecken)
+# Spaeter hinzugekommene Faelle: GOLDEN_MODE=record-missing legt nur FEHLENDE Snapshots an und
+# vergleicht vorhandene (sicherer als "record", das alles ueberschreibt).
+ORIGINAL_DIR=./.original/lmo_php8-master LMO_SOURCE=/original GOLDEN_MODE=record \
+ docker compose run --rm tests
+
+# 3. Gegen den aktuellen Arbeitsstand prüfen (nach jedem Refactoring-Schritt)
+docker compose run --rm tests
+
+# Nur eine Gruppe oder ein Fall:
+docker compose run --rm tests --filter CalculationTest
+docker compose run --rm tests --filter 'PagesTest.*action=table'
+
+# Andere PHP-Version (README nennt 8.0 bis 8.4)
+PHP_VERSION=8.0 docker compose run --rm tests
+```
+
+Ohne Docker: `composer install && php vendor/bin/phpunit` (PHP ≥ 8.0, optional `faketime`).
+
+**Wichtig:** Snapshots nur am unveränderten Original aufzeichnen und nur dann neu aufzeichnen, wenn
+eine Abweichung geprüft und gewollt ist. Eine Abweichung ist zunächst ein Befund, kein Grund
+zum Aktualisieren.
+
+## So werden Abweichungen angezeigt
+- HTML wird normalisiert (Session-IDs, Temp-Pfade, Zufallsfarben der Fieberkurve entfernt,
+ Whitespace zusammengefasst, ein Tag pro Zeile). Dadurch stören Einrückungsänderungen durch
+ Twig nicht, Strukturänderungen aber schon.
+- Numerische Strings und Zahlen gelten in `CalculationTest` als gleich (`"0"` = `0`), weil das
+ Original `array_pad(..., "0")` benutzt.
+- PHP-Warnungen und -Notices erscheinen als `` im Snapshot. Neue Warnungen
+ nach einem Refactoring führen also ebenfalls zu einer Abweichung.
+
+## Bekannte Grenzen
+- **Nicht abgedeckt:** Admin-Bereich (Speichern in `.l98`), KO-Ligen (`Type` ungleich 0), Tippspiel,
+ Spielerstatistik, `savehtml`, Mailversand, erzeugte Grafiken (`lmo-gif`). Der Schreibpfad ist der
+ nächste Baustein: Admin-Aktionen mit Sitzung nachspielen und die geschriebene Datei vergleichen.
+- **Zeit:** `faketime` setzt die Uhr auf 2025-03-15 12:00 (läuft danach weiter). Ohne `faketime`
+ hängen die Kalenderseiten vom Tagesdatum ab. Die Berechnungszeit ist in der Testinstanz abgeschaltet.
+- **GET überschreibt Konfiguration:** `init.php` extrahiert `$_GET` nach dem Laden von `cfg.txt`
+ in den globalen Scope. Die Tests nutzen das für `tabonres=2` und `tabpkt=0`. Es ist zugleich
+ ein Sicherheitsproblem des Originals (jede Konfigurationsvariable lässt sich per URL setzen).
+- Die Kalenderseite (`action=cal`) hängt von Optionen der Liga ab und kann leer sein; das ist dann
+ Teil des Golden Masters.
+
+## Coverage-Messung: welcher Code wird vom Golden Master nie ausgeführt?
+
+Misst mit Xdebug (nur in den Kindprozessen aktiv), welche Zeilen von `lmo/` während aller Tests laufen (jeder Kindprozess schreibt
+seine Abdeckung, am Ende wird zusammengeführt). Damit wird "deckt der Golden Master alles ab" zu
+einer Zahl je Datei.
+
+```powershell
+docker compose build tests # einmalig, installiert Xdebug im Image
+$env:GOLDEN_COVERAGE = "1"
+docker compose run --rm tests
+Remove-Item Env:GOLDEN_COVERAGE
+```
+
+Ergebnis auf der Konsole (Zusammenfassung je Bereich + Kerndateien, schlechteste zuerst) und in
+`coverage/`: `report.txt` (alle Dateien), `report.csv` (für Excel, Trennzeichen `;`) und
+`uncovered.txt` (nicht ausgeführte Zeilenbereiche je Datei).
+
+Bedeutung: "ausgeführt" heißt, die Zeile lief mindestens einmal. Es heißt nicht, dass eine
+Änderung dort auffallen würde. Ein Fehler in einer Zeile, deren Ergebnis nirgends in der Ausgabe
+landet, bleibt unbemerkt. Die Zahl ist eine Untergrenze für Lücken, kein Beweis für Sicherheit.
+Bei nie geladenen Dateien ist die Zeilenzahl geschätzt (`~`).
+
+### Bekanntes Problem: pcov stuerzt ab
+Mit pcov endeten die Kindprozesse mit Exit-Code 139 (Segmentation fault), Coverage-Daten wurden nie
+geschrieben. Deshalb nutzt die Messung Xdebug. Die Coverage-Messung laeuft ohne `faketime`;
+zeitabhaengige Seiten (Kalender) koennen im Coverage-Lauf vom Snapshot abweichen. Das ist hier
+unerheblich, der Report wird trotzdem erzeugt. Mit `GOLDEN_NO_FAKETIME=1` laesst sich `faketime`
+auch ausserhalb der Messung abschalten.
+
+Achtung PowerShell: `$env:GOLDEN_COVERAGE` bleibt in der Sitzung gesetzt, bis es mit
+`Remove-Item Env:GOLDEN_COVERAGE` entfernt wird. Die erste Ausgabezeile des Laufs zeigt
+`Coverage: 1` oder `Coverage: 0`.
+
+Tipp: `docker compose run --rm tests --stop-on-failure` bricht nach dem ersten Fehler ab und
+vermeidet seitenlange Ausgaben.
+
+### Hermetische Tests: kein Netzwerkzugriff
+`lmo-functions.php` fragt bei **jedem** Seitenaufruf die Update-URL aus `composer.json`
+(`extra.check`, vest-sport.de) ab, per `get_headers()` und `file_get_contents()`. Das Ergebnis wird
+nur im Admin-Bereich angezeigt. Antwortet der Server langsam, entstehen PHP-Warnungen und
+ein Test schlaegt zufaellig fehl (so gesehen bei `12er-liga.l98&action=stats`). Die Testprozesse
+laufen deshalb mit `allow_url_fopen=0`; die Abfrage nutzt dann ihren lokalen Fallback.
+Bestehende Snapshots bleiben gueltig, weil die oeffentlichen Seiten das Ergebnis nicht verwenden.
+
+### Snapshot-Modi
+| `GOLDEN_MODE` | Verhalten |
+|---|---|
+| (leer) | Vergleichen. Fehlender Snapshot = Fehler |
+| `record-missing` | Fehlende Snapshots anlegen, vorhandene vergleichen. Standard fuer neue Testfaelle |
+| `record` | **Alle** Snapshots neu schreiben. Nur am unveraenderten Original und nur bewusst |
+
+Neue Szenarien (`direct_kegel`, `direct_special`, `direct_wertung`, `direct_handicap`) pruefen den
+direkten Vergleich mit Kegelwertung, Sonderpunkten, Wertung am gruenen Tisch und Handicap; sie
+testen nur Tabellenseiten und die Berechnung (`pages => table`).
+
+### Szenarien nach Coverage-Auswertung (v7)
+Lücken des ersten Coverage-Laufs und was sie schließt:
+| Lücke (Original) | Ursache | Szenario |
+|---|---|---|
+| `lmo-calctable1.php` 68-76 | Sonderpunkte im direkten Vergleich | `direct_special` |
+| `lmo-calctable1.php` 84-93, 121-130 | Wertung am grünen Tisch im direkten Vergleich | `direct_wertung` |
+| `lmo-calctable1.php` 163 | Kegelwertung im direkten Vergleich | `direct_kegel` |
+| `lmo-showtable.php` 144-145, 188, 229-230 | Favorit, Vereins-URL, Teamnotiz | `viewopts` |
+| `lmo-showstats.php` 59-296 | Statistik-Vergleich braucht `stat1`/`stat2` | Seiten `action=stats&stat1=...` |
+| `lmo-calctable.php` 100-102, 117-119, 185-187, 202-204 | nach Analyse nicht erreichbar (siehe unten) | keines |
+| `lmo-calctable*.php` 23, 26 | Standardwerte, wenn `$tabtype`/`$newtabtype` fehlen | keines |
+
+Die vier Blöcke in `lmo-calctable.php` aktualisieren den "höchsten Sieg" bzw. die "höchste
+Niederlage" aus einem Ergebnis, das per Wertung entschieden wurde. Beim Einlesen setzt `lmo-openfile.php`
+dafür ein Tor auf `0`. Dann kann die Tordifferenz den Startwert 0 nie übertreffen, die Bedingung
+ist nie wahr. Das ist eine Herleitung aus dem Code, kein Beweis durch Test.
+
+### v8: letzte kleine Lücken
+| Lücke | Szenario/Seite |
+|---|---|
+| `lmo-calctable1.php` 127-130 (Gastteam verliert durch Heim-Wertung im direkten Vergleich) | `direct_wertung_many` (viele Wertungsspiele) |
+| `lmo-showstats.php` 26-27 (`stat2` ohne `stat1`) | Seite `action=stats&stat1=0&stat2=2` |
+
+Nicht abgedeckt bleiben bewusst: die vier Wertungsblöcke in `lmo-calctable.php` (nicht erreichbar, s. o.)
+und die Standardwerte in Zeile 23/26 (`$tabtype` ist im Webablauf immer gesetzt).
+
+## Admin-Bereich (v9)
+
+Neu: `bin/http.php` fuehrt beliebige GET/POST-Anfragen gegen jeden Einstiegspunkt aus, auf Wunsch
+innerhalb einer Sitzung (`AdminClient` merkt sich die Sitzungs-ID zwischen den Schritten).
+`StateTracker` meldet nach jedem Schritt, welche Dateien neu, geaendert oder geloescht sind (als
+Unified Diff). Kennwort-Hashes (Zufalls-Salt) und die Laufzeit in der Fusszeile werden normalisiert.
+
+| Test | Prueft |
+|---|---|
+| `AdminViewsTest` | ca. 60 lesende Admin-Ansichten als Hauptadmin (Startseite, Neu, Oeffnen, Loeschen, Upload, Download, Optionen, Addons, Design, Benutzer, Update, Tippspiel-Seiten, Viewer; je Liga Bearbeiten/Grunddaten/Mannschaften/Anzahl/Spieltage/Tabellenkorrektur). Der Snapshot enthaelt auch Dateien, die der Seitenaufruf veraendert hat (normalerweise keine) |
+| `AdminLoginTest` | Anmeldung (falsch/richtig), Abmelden, Kennwort-Upgrade auf Hash, Hilfsadmin und erweiterter Hilfsadmin (erlaubte und gesperrte Seiten) |
+| `StyleTest` | `lmo-style.php`, `lmo-style-nc.php` |
+
+Pruefen, ob das Admin-Geruest funktioniert (nach dem ersten `record-missing`-Lauf):
+```powershell
+# darf keinen Treffer liefern: sonst wurde die Sitzung nicht uebernommen und alle Seiten zeigen das Anmeldeformular
+Select-String -Path tests\GoldenMaster\__snapshots__\admin-views\*.txt -Pattern 'name="xusername"' -List | Measure-Object
+# Anmeldeablauf ansehen
+Get-Content tests\GoldenMaster\__snapshots__\admin-flows\login_logout-*.txt | Select-String "^===|GEAENDERT|NEU |HASH"
+```
+
+Noch **nicht** enthalten: schreibende Admin-Aktionen (Ergebnisse speichern, Mannschaften, Grunddaten,
+Optionen, Benutzer, neue Liga, Loeschen, Upload). Die kommen als naechster Baustein.
+
+## Schreibende Admin-Aktionen (v10)
+
+`FormSubmitter` liest ein Formular wie ein Browser aus (Standardwerte, Auswahl, Haken) und `AdminClient`
+schickt es mit gezielten Abweichungen ab. `Fixture::loggedInClient()` liefert je Test eine frische, angemeldete
+Instanz; jedes Szenario ist von den anderen unabhaengig.
+
+| Test | Prueft |
+|---|---|
+| `AdminWriteTest` | Ergebnisse/Termine/Notizen speichern, Grunddaten, Mannschaften inkl. Straf- und Torkorrektur, Anzahl Spieltage, Tabellenkorrektur, Optionen, Design, Benutzer anlegen/bearbeiten/loeschen, Liga loeschen, Liga neu anlegen (4 Schritte) |
+| `AdminRoundtripTest` | jedes Formular unveraendert abschicken (Optionen, Addons, Design, Benutzer, Viewer, Tippspiel, je Liga Ergebnisse/Grunddaten/Mannschaften/Anzahl/Spieltage/Tabellenkorrektur) |
+| `AdminAuthProbeTest` | Sicherheitsprobe: Admin-Bereich und direkt aufrufbare Include-Dateien ohne Anmeldung |
+
+Protokollformat eines Schritts: `=== Titel ===`, gesendete Felder, Antwort, `--- Dateiaenderungen ---` (Diff).
+Steht dort `KEIN FORMULAR GEFUNDEN` oder `LEERES FORMULAR`, hat der Test das Formular nicht gefunden
+(falsche Annahme, nicht Verhalten der Anwendung).
+
+Fixture-Aenderung gegenueber v9: `Instance` legt jetzt auch an, was der Installer anlegen laesst
+(`addon/tipp/lmo-tippauth.txt`, Tipp- und Spieler-Verzeichnisse). Ohne diese Datei bricht
+`todo=tippuser` mit einem Fatal Error ab. Dadurch aendern sich einige Snapshots unter
+`admin-views` (`*todo=tipp*`); sie muessen neu aufgezeichnet werden.
+
+Nicht abgedeckt: Datei-Upload (`todo=upload`), Download als Datei (`lmo-admindownload.php` mit gueltiger Sitzung),
+Zufallsspielplan (`xprogram=random`, nicht reproduzierbar), JavaScript im Browser.
+
+### Normalisierung von Stack-Traces (v11)
+PHP-Fehlermeldungen im Snapshot enthalten einen Stack-Trace mit den Hilfsskripten der Tests
+(`.../bin/http.php(57)`). Wird ein Hilfsskript geaendert, verschiebt sich die Zeilennummer und der
+Snapshot wird rot, obwohl sich die Anwendung nicht geaendert hat. Der Normalizer ersetzt diese
+Angaben durch `{HARNESS}/http.php(N)`. Snapshots, die einen Stack-Trace enthalten, einmal neu aufzeichnen
+(betrifft bisher `todo=pdfoptions` und `todo=tippuser`).
+
+### Uhrzeit nach dem Speichern (v12)
+`faketime` setzt nur die Uhr, nicht die Dateizeit: Eine vom Admin gespeicherte Ligadatei hat die echte Zeit.
+Die oeffentlichen Seiten zeigen sie als "Letztes Update der Liga". In Admin-Protokollen ersetzt der Normalizer
+diese Angabe durch `{STAND}`. Betroffene Snapshots (`admin-write`: results, basic_data, teams,
+table_correction, create_league) muessen einmal neu aufgezeichnet werden.
+Tipp: Nach dem Aufzeichnen zwei Vergleichslaeufe mit mindestens einer Minute Abstand starten,
+sonst bleiben Zeitabhaengigkeiten unbemerkt.
+
+### Coverage-Report: Admin getrennt ausgewiesen (v13)
+Der Bereichsbericht zeigt jetzt "Kern: oeffentlich" und "Kern: Admin" getrennt (vorher ein
+gemeinsames "Kern (lmo/*.php)"), damit sichtbar ist, wie weit der Admin-Bereich abgedeckt ist,
+ohne ihn mit den oeffentlichen Seiten zu vermischen. report.csv/uncovered.txt unveraendert.
+
+## Verbleibende Kern-Luecken geschlossen (v14)
+
+Nach der getrennten Admin-/oeffentlich-Auswertung (v13) wurden die uebrig gebliebenen 0%-Dateien
+im Kern einzeln geprueft:
+
+| Datei | Befund | Massnahme |
+|---|---|---|
+| `index.php` | reiner Redirect auf `lmo.php` | `CoreGapsTest::testIndexRedirectsToLmoPhp` |
+| `lmo-adminopenprogram.php` | Spielplan "aus Datei uebernehmen"; `$xprogram` wird ungeprueft an `fopen()` uebergeben (nur Suffix `.l98` verlangt) - eigener, kleiner Befund zum Pfad-Handling, siehe Klassenkommentar in `CoreGapsTest.php` | `CoreGapsTest::testCreateLeagueFromTemplate` |
+| `lmo-adminrndprogram.php` | erzeugt einen echten Zufallsspielplan (nicht deterministisch) | `RandomScheduleSmokeTest`: kein Golden-Master-Vergleich, sondern Plausibilitaetspruefung (jedes Team pro Spieltag genau einmal) + Pruefung auf PHP-Fehler |
+| `lmo-admindir.php` | **toter Code** - keine einzige Referenz im gesamten Repository (auch nicht in Addons) | bewusst nicht getestet, bleibt bei 0% |
+| `lmo-paintgraph.php` | **toter Code** - keine Referenz; das Tippspiel-Addon hat eine eigene, gleichnamige Kopie (`lmo-tipppaintgraph.php`), die unabhaengig davon existiert | bewusst nicht getestet, bleibt bei 0% |
+| `lmo-adminuserpass.php`, `lmo-openfiledat.php` | nur vom Tippspiel-Addon genutzt (Zufallspasswort bei neuem Tipper bzw. E-Mail-Versand mit Spieltagsbezug) | zurueckgestellt, gehoert zum separaten Block "Tippspiel-Addon" (aktuell 4% Abdeckung, 64 Dateien) |
+
+KO-Ligen (`Type=1`: `lmo-showkoprogram.php`, `lmo-showkoresults.php`, Teile von `lmo-adminnew.php`)
+sind bewusst **nicht** Teil dieser Runde: Das ist ein eigener, groesserer Baustein (eigene
+Fixture-Struktur mit Turniermodus/Spieltagsmodi, eigene Admin-Formularfelder), vergleichbar im
+Umfang mit dem Tippspiel-Addon. Die einzige reale Liga im Repo (`1l_2024-25.l98`) ist `Type=0`.
+
+## Dritter Befund: Spielplan-Bug in lmo-adminrndprogram.php (v15)
+
+`RandomScheduleSmokeTest` deckte beim ersten echten Lauf einen eigenstaendigen Fehler im
+unveraenderten Original auf (unabhaengig von extract() und dem xprogram-Pfadproblem):
+
+**Fisher-Yates-Shuffle mit Off-by-One** (~Zeile 160): `$j = @mt_rand(0, $i+1);` statt
+`mt_rand(0, $i)`. Beim ersten Schleifendurchlauf (`$i` = Teamzahl-1) liegt die Obergrenze um 1 zu
+hoch; mit Wahrscheinlichkeit 1/(Teamzahl+1) wird ein Index ausserhalb des Arrays gezogen. Das
+erzeugt nicht nur eine "Undefined array key"-Warnung, sondern laesst eine Teamposition auf `NULL`
+stehen - der erzeugte Zufallsspielplan verliert ein Team. Bei 6 Teams: ca. 14% der Aufrufe.
+
+Der Test toleriert nur GENAU diese eine bekannte Warnung (exakter Text, inkl. Dateipfad und
+Zeilennummer) und prueft dann zusaetzlich, ob der Spielplan dadurch tatsaechlich ein Team verliert.
+Jede andere/unerwartete PHP-Meldung laesst den Test weiterhin scheitern. Weil der Fehler
+probabilistisch ist, zeigt ein einzelner Testlauf nur einen der beiden Zweige (Bug getroffen
+oder nicht); beide Zweige sind im Test abgedeckt und werden ueber mehrere Laeufe hinweg beide
+auftreten.
+
+**Korrektur waere** `mt_rand(0, $i+1)` zu `mt_rand(0, $i)` zu aendern - das ist eine echte
+Verhaltensaenderung (das Zufallsergebnis wird dadurch anders/korrekter) und daher bewusst NICHT
+Teil des Golden Masters, sondern ein eigener, separat zu entscheidender Fix, wie die beiden
+anderen Befunde (extract() in init.php, ungeprueftes $xprogram in lmo-adminopenprogram.php).
+
+## KO-Ligen / Pokalmodus (v16)
+
+Eigenes Datenformat (`Type=1`): Ergebnisfelder sind "Spiel+Durchgang"-kodiert (z. B. `GA00` =
+Spiel 1, 1. Durchgang), Team-Zuordnung je Runde erfolgt manuell durch den Admin (kein
+automatisches Nachruecken der Sieger). Modus je Runde: 1=Einzelspiel, 2=Hin-/Rueckspiel,
+3/5/7=Best-of-3/5/7 (steuert, wie viele "Durchgaenge" pro Spiel moeglich sind).
+
+`KoLeagueTest` erzeugt die Liga ueber den echten Admin-Assistenten (`todo=new&xtype=1`) statt als
+Fixture-Datei nachzubauen - das Dateiformat ist komplex genug, dass der "richtige" Weg ueber die
+Anwendung selbst zuverlaessiger ist. Abgedeckt: 4er-Turnierbaum (Halbfinale + Finale),
+Einzelspiel-Modus (Standard), oeffentliche Seiten (`action=program&selteam=N`,
+`action=results&st=N`, `action=cal`), Admin-Bearbeitung und ein Roundtrip (unveraendert speichern).
+
+NICHT abgedeckt (eigener, groesserer Block): Hin-/Rueckspiel und Best-of-3/5/7 (Modus 2/3/5/7),
+Playoffmode-Varianten (2-2-1 usw., siehe `$playoffmode` in `lmo-adminedit.php`/
+`lmo-showkoresults.php`), Spielerstatistik-Addon fuer KO, Tippspiel-Addon fuer KO
+(`lmo-tippeditko.php` und Verwandte), groessere Turnierbaeume (8/16/24/32+ Teams).
diff --git a/composer.json b/composer.json
new file mode 100644
index 00000000..fbfd3f3d
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "lmo/lmo-php8-tests",
+ "description": "Entwicklungswerkzeuge (Golden-Master-Tests) fuer lmo_php8",
+ "type": "project",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Lmo\\Tests\\GoldenMaster\\": "tests/GoldenMaster/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "test:record": "GOLDEN_MODE=record phpunit"
+ },
+ "config": {
+ "platform": {
+ "php": "8.0.30"
+ },
+ "sort-packages": true
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 00000000..5f88838f
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,1804 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "ff7420fe4c07935607b1002ea5ada05a",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^0.16 || ^1",
+ "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan-phpunit": "^1",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "vimeo/psalm": "^4.30 || ^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/1.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-30T00:15:36+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-11T10:17:44+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
+ },
+ "time": "2026-09-13T18:51:52+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "9.2.32",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-text-template": "^2.0.4",
+ "sebastian/code-unit-reverse-lookup": "^2.0.3",
+ "sebastian/complexity": "^2.0.3",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/lines-of-code": "^1.0.4",
+ "sebastian/version": "^3.0.2",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.2.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:23:01+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "3.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-12-02T12:48:52+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:16:10+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "9.6.38",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8b5f757e8e92071bfdbfa93943f94f84c4e31007",
+ "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.5.0 || ^2",
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=7.3",
+ "phpunit/php-code-coverage": "^9.2.32",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.4",
+ "phpunit/php-timer": "^5.0.3",
+ "sebastian/cli-parser": "^1.0.2",
+ "sebastian/code-unit": "^1.0.8",
+ "sebastian/comparator": "^4.0.10",
+ "sebastian/diff": "^4.0.6",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/exporter": "^4.0.9",
+ "sebastian/global-state": "^5.0.8",
+ "sebastian/object-enumerator": "^4.0.4",
+ "sebastian/resource-operations": "^3.0.4",
+ "sebastian/type": "^3.2.1",
+ "sebastian/version": "^3.0.2"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.38"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-10-05T05:44:26+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:27:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:30:19+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "4.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-24T09:22:56+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:19:30+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "4.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:30:58+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "5.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:03:51+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "4.0.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
+ "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-11T04:55:59+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "5.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T07:10:35+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:20:34+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:12:34+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:14:26+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "4.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "c85be6922b7fd365942b986b9a50397d65407611"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611",
+ "reference": "c85be6922b7fd365942b986b9a50397d65407611",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-11T05:25:24+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-14T16:00:52+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:13:03+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:39:44+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-17T20:03:58+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.0"
+ },
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "8.0.30"
+ },
+ "plugin-api-version": "2.9.0"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..cbca1754
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+# Aufruf siehe TESTING.md
+services:
+ tests:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile
+ args:
+ PHP_VERSION: ${PHP_VERSION:-8.3}
+ image: lmo-golden-tests:php${PHP_VERSION:-8.3}
+ volumes:
+ - .:/app
+ # unveraenderter Originalstand fuer das Aufzeichnen der Snapshots (optional)
+ - ${ORIGINAL_DIR:-./.original}:/original:ro
+ environment:
+ LMO_SOURCE: ${LMO_SOURCE:-/app}
+ GOLDEN_MODE: ${GOLDEN_MODE:-compare}
+ GOLDEN_COVERAGE: ${GOLDEN_COVERAGE:-0}
+ GOLDEN_NO_FAKETIME: ${GOLDEN_NO_FAKETIME:-0}
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 00000000..93e469f2
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,25 @@
+# Testumgebung fuer die Golden-Master-Tests.
+# PHP-Version waehlbar: PHP_VERSION=8.1 docker compose build tests
+ARG PHP_VERSION=8.3
+FROM php:${PHP_VERSION}-cli
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ git unzip faketime \
+ libpng-dev libjpeg-dev libfreetype6-dev \
+ && docker-php-ext-configure gd --with-freetype --with-jpeg \
+ && docker-php-ext-install -j"$(nproc)" gd \
+ && rm -rf /var/lib/apt/lists/*
+
+# Xdebug als Coverage-Treiber (pcov stuerzt hier mit Segmentation fault ab).
+# Standardmaessig aus: kein Overhead und keine zusaetzliche Ausgabe. Nur die Kindprozesse der
+# Coverage-Messung starten mit -d xdebug.mode=coverage.
+RUN pecl install xdebug && docker-php-ext-enable xdebug \
+ && echo "xdebug.mode=off" > /usr/local/etc/php/conf.d/zz-xdebug-mode.ini
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+WORKDIR /app
+COPY docker/run-tests.sh /usr/local/bin/run-tests
+RUN chmod +x /usr/local/bin/run-tests
+ENTRYPOINT ["run-tests"]
diff --git a/docker/run-tests.sh b/docker/run-tests.sh
new file mode 100644
index 00000000..aa1a92fc
--- /dev/null
+++ b/docker/run-tests.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+cd /app || exit 1
+if [ ! -x vendor/bin/phpunit ]; then
+ composer install --no-interaction --no-progress --prefer-dist || exit 1
+fi
+echo "PHP $(php -r 'echo PHP_VERSION;'), Quelle: ${LMO_SOURCE:-/app}, Modus: ${GOLDEN_MODE:-compare}, Coverage: ${GOLDEN_COVERAGE:-0}"
+
+coverage=${GOLDEN_COVERAGE:-0}
+if [ "$coverage" = "1" ]; then
+ if ! php -m | grep -qi '^xdebug$'; then
+ echo "Xdebug ist nicht geladen. Image neu bauen: docker compose build tests" >&2
+ exit 1
+ fi
+ rm -rf coverage
+ mkdir -p coverage/raw
+fi
+
+status=0
+vendor/bin/phpunit "$@" || status=$?
+
+if [ "$coverage" = "1" ]; then
+ php tests/GoldenMaster/bin/coverage_report.php /app || true
+fi
+exit $status
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 00000000..2e7c74b6
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,11 @@
+
+
+
+
+ tests/GoldenMaster
+
+
+
diff --git a/tests/GoldenMaster/AdminAuthProbeTest.php b/tests/GoldenMaster/AdminAuthProbeTest.php
new file mode 100644
index 00000000..a377a75b
--- /dev/null
+++ b/tests/GoldenMaster/AdminAuthProbeTest.php
@@ -0,0 +1,67 @@
+add('ohne Anmeldung: Optionen', $c->get('lmoadmin.php', $a . '&todo=options'), $c->changes());
+ $t->add('ohne Anmeldung: Parameter lmouserok', $c->get('lmoadmin.php', $a . '&todo=options&lmouserok=2'), $c->changes());
+ $t->add('ohne Anmeldung: Sitzungswert im Parameter', $c->get('lmoadmin.php', $a . '&todo=options&_SESSION[lmouserok]=2'), $c->changes());
+ $t->add('falsches Kennwort plus Parameter', $c->post('lmoadmin.php', '', [
+ 'action' => 'admin', 'xusername' => 'admin', 'xuserpass' => 'falsch', 'lmouserok' => '2',
+ ]), $c->changes());
+ $t->add('Download ohne Anmeldung', $c->get('lmo-admindownload.php', $a . '&todo=download&down=1'), $c->changes());
+ $t->add('Download mit Sitzungswert im Parameter', $c->get('lmo-admindownload.php', $a . '&todo=download&down=1&_SESSION[lmouserok]=2'), $c->changes());
+
+ $this->assertMatchesSnapshot('security-probe', 'admin_without_login', $t->text());
+ }
+
+ public function testDirectAccessToIncludeFiles(): void
+ {
+ $c = Fixture::anonymousClient();
+ $t = new Transcript();
+
+ foreach ([
+ 'lmo-adminmain.php' => 'action=admin',
+ 'lmo-adminedit.php' => 'action=admin&todo=edit&file=golden/basic.l98&st=1',
+ 'lmo-savefile.php' => 'file=golden/basic.l98',
+ 'lmo-adminmimesend.php' => '',
+ 'lmo-adminjavascript.php' => '',
+ 'lmo-admincal.php' => '',
+ 'lmo-adminrounds.php' => 'file=golden/basic.l98',
+ 'lmo-adminsubnavi.php' => 'file=golden/basic.l98',
+ 'lmo-savehtml.php' => 'file=golden/basic.l98',
+ 'lmo-kocal.php' => '',
+ 'lmo-rueckrunde.php' => 'file=golden/basic.l98',
+ 'lmo-dirlist.php' => '',
+ 'lmo-openfile.php' => 'file=golden/basic.l98',
+ ] as $script => $query) {
+ $t->add('direkter Aufruf ' . $script, $c->get($script, $query), $c->changes());
+ }
+
+ $this->assertMatchesSnapshot('security-probe', 'direct_include_access', $t->text());
+ }
+}
diff --git a/tests/GoldenMaster/AdminLoginTest.php b/tests/GoldenMaster/AdminLoginTest.php
new file mode 100644
index 00000000..1a002d27
--- /dev/null
+++ b/tests/GoldenMaster/AdminLoginTest.php
@@ -0,0 +1,70 @@
+add('GET Anmeldeformular', $c->get('lmoadmin.php', 'action=admin'), $c->changes());
+ $t->add('POST falsches Kennwort', $c->login('admin', 'falsch'), $c->changes());
+ $t->add('POST unbekannter Benutzer', $c->login('niemand', 'lmo'), $c->changes());
+ $t->add('POST richtiges Kennwort (Klartext in der Datei)', $c->login('admin', 'lmo'), $c->changes());
+ $t->add('GET Startseite Admin', $c->get('lmoadmin.php', 'action=admin'), $c->changes());
+ $t->add('GET Abmelden', $c->get('lmoadmin.php', 'action=admin&todo=logout'), $c->changes());
+ $t->add('GET nach dem Abmelden', $c->get('lmoadmin.php', 'action=admin'), $c->changes());
+ $t->add('POST Anmeldung mit bereits gehashtem Kennwort', $c->login('admin', 'lmo'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-flows', 'login_logout', $t->text());
+ }
+
+ public function testHelperAdminRoles(): void
+ {
+ [$app, $runner] = Fixture::freshInstance();
+ file_put_contents(
+ $app->path() . '/config/lmo-auth.php',
+ "\nadmin|lmo|2|||\nhilfe|geheim|1|basic|0\nerweitert|geheim|1|basic|1\n"
+ );
+ $c = new AdminClient($app, $runner);
+ $t = new Transcript();
+ $a = 'action=admin';
+
+ $t->add('Hilfsadmin: Anmeldung', $c->login('hilfe', 'geheim'), $c->changes());
+ $t->add('Hilfsadmin: Startseite', $c->get('lmoadmin.php', $a), $c->changes());
+ $t->add('Hilfsadmin: eigene Liga bearbeiten', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/basic.l98&st=1'), $c->changes());
+ $t->add('Hilfsadmin: Grunddaten der eigenen Liga', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/basic.l98&st=-1'), $c->changes());
+ $t->add('Hilfsadmin: Mannschaften (nur erweitert)', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/basic.l98&st=-2'), $c->changes());
+ $t->add('Hilfsadmin: fremde Liga', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/special.l98&st=1'), $c->changes());
+ $t->add('Hilfsadmin: Liga anlegen (nicht erlaubt)', $c->get('lmoadmin.php', $a . '&todo=new'), $c->changes());
+ $t->add('Hilfsadmin: Optionen (nicht erlaubt)', $c->get('lmoadmin.php', $a . '&todo=options'), $c->changes());
+ $t->add('Hilfsadmin: Liga oeffnen', $c->get('lmoadmin.php', $a . '&todo=open'), $c->changes());
+ $t->add('Hilfsadmin: Download', $c->get('lmoadmin.php', $a . '&todo=download&file=golden/basic.l98'), $c->changes());
+ $t->add('Hilfsadmin: Tabellenkorrektur', $c->get('lmoadmin.php', $a . '&todo=tabs&file=golden/basic.l98'), $c->changes());
+ $t->add('Hilfsadmin: Abmelden', $c->get('lmoadmin.php', $a . '&todo=logout'), $c->changes());
+
+ $t->add('Erweiterter Hilfsadmin: Anmeldung', $c->login('erweitert', 'geheim'), $c->changes());
+ $t->add('Erweiterter Hilfsadmin: Mannschaften', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/basic.l98&st=-2'), $c->changes());
+ $t->add('Erweiterter Hilfsadmin: Anzahl', $c->get('lmoadmin.php', $a . '&todo=edit&file=golden/basic.l98&st=-3'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-flows', 'helper_roles', $t->text());
+ }
+}
diff --git a/tests/GoldenMaster/AdminRoundtripTest.php b/tests/GoldenMaster/AdminRoundtripTest.php
new file mode 100644
index 00000000..a3d80d4c
--- /dev/null
+++ b/tests/GoldenMaster/AdminRoundtripTest.php
@@ -0,0 +1,63 @@
+submitForm('lmoadmin.php', $query, $field);
+ $changes = $c->changes();
+
+ $this->assertMatchesSnapshot(
+ 'admin-roundtrip',
+ $query,
+ $response . "\n### DATEIAENDERUNGEN\n" . ($changes === '' ? "(keine)\n" : $changes)
+ );
+ }
+
+ public static function forms(): array
+ {
+ $a = 'action=admin';
+ $cases = [];
+ $add = static function (string $query, ?string $field) use (&$cases): void {
+ $cases[$query] = [$query, $field];
+ };
+
+ $add($a . '&todo=options', 'xadr');
+ $add($a . '&todo=addons', null);
+ $add($a . '&todo=design', 'xlmo_main_background1');
+ $add($a . '&todo=user&show=0', 'xadmin_name0');
+ $add($a . '&todo=vieweroptions', null);
+ $add($a . '&todo=tippoptions', null);
+ $add($a . '&todo=tippemail', null);
+ $add($a . '&todo=tipp', null);
+
+ foreach (['golden/basic.l98', 'golden/special.l98', 'golden/handicap.l98', 'golden/wertung.l98'] as $file) {
+ $f = '&file=' . $file;
+ $add($a . '&todo=edit' . $f . '&st=1', 'xgoala0');
+ $add($a . '&todo=edit' . $f . '&st=-1', 'xtitel');
+ $add($a . '&todo=edit' . $f . '&st=-2', 'xteams1');
+ $add($a . '&todo=edit' . $f . '&st=-3', 'xanzst');
+ $add($a . '&todo=edit' . $f . '&st=-10', null);
+ $add($a . '&todo=tabs' . $f . '&st=3', 'xplatz1');
+ }
+ return $cases;
+ }
+}
diff --git a/tests/GoldenMaster/AdminViewsTest.php b/tests/GoldenMaster/AdminViewsTest.php
new file mode 100644
index 00000000..f02f42d2
--- /dev/null
+++ b/tests/GoldenMaster/AdminViewsTest.php
@@ -0,0 +1,39 @@
+get($script, $query);
+ $changes = $client->changes();
+
+ $this->assertMatchesSnapshot(
+ 'admin-views',
+ $script . '?' . $query,
+ $html . "\n### DATEIAENDERUNGEN\n" . ($changes === '' ? "(keine)\n" : $changes)
+ );
+ }
+
+ public static function views(): array
+ {
+ return AdminMatrix::views();
+ }
+}
diff --git a/tests/GoldenMaster/AdminWriteTest.php b/tests/GoldenMaster/AdminWriteTest.php
new file mode 100644
index 00000000..b9b525f8
--- /dev/null
+++ b/tests/GoldenMaster/AdminWriteTest.php
@@ -0,0 +1,188 @@
+add('Spieltag 1: Ergebnisse, Termine, Notiz, Bericht', $c->submitForm('lmoadmin.php', $edit . '1', 'xgoala0', [
+ 'xgoala0' => '3', 'xgoalb0' => '1',
+ 'xgoala1' => '', 'xgoalb1' => '',
+ 'xatdat0' => '15.03.2025', 'xattim0' => '18:30',
+ 'xdatum1' => '14.03.2025', 'xdatum2' => '16.03.2025',
+ 'xmnote0' => 'Notiz & Test', 'xmberi0' => 'http://example.org/bericht',
+ ]), $c->changes());
+ $t->add('Spieltag 1 erneut oeffnen', $c->get('lmoadmin.php', $edit . '1'), $c->changes());
+ $t->add('Spieltag 2: Wertung und Spiel ohne Ergebnis', $c->submitForm('lmoadmin.php', $edit . '2', 'xgoala0', [
+ 'xgoala0' => '0', 'xgoalb0' => '2', 'xmsieg0' => '1',
+ 'xgoala1' => '1', 'xgoalb1' => '1', 'xmsieg1' => '3',
+ 'xgoala2' => '_', 'xgoalb2' => '_',
+ ]), $c->changes());
+ $t->add('oeffentliche Tabelle danach', $c->get('lmo.php', 'file=golden/basic.l98&action=table'), $c->changes());
+ $t->add('oeffentliche Ergebnisse Spieltag 1', $c->get('lmo.php', 'file=golden/basic.l98&action=results&st=1'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'results', $t->text());
+ }
+
+ public function testBasicData(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $q = self::A . '&todo=edit' . self::BASIC . '&st=-1';
+
+ $t->add('Grunddaten speichern', $c->submitForm('lmoadmin.php', $q, 'xtitel', [
+ 'xtitel' => 'Geaenderter Titel', 'xpns' => '2', 'xpnu' => '1', 'xpnn' => '0',
+ 'xminus' => '2', 'xdirekt' => '1', 'xkegel' => '1', 'xanzcl' => '1', 'xchamp' => '1',
+ 'xfavteam' => '2', 'xurlt' => '1',
+ ]), $c->changes());
+ $t->add('Grunddaten erneut oeffnen', $c->get('lmoadmin.php', $q), $c->changes());
+ $t->add('oeffentliche Tabelle danach', $c->get('lmo.php', 'file=golden/basic.l98&action=table'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'basic_data', $t->text());
+ }
+
+ public function testTeams(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $q = self::A . '&todo=edit' . self::BASIC . '&st=-2';
+
+ $t->add('Mannschaften speichern', $c->submitForm('lmoadmin.php', $q, 'xteams1', [
+ 'xteams1' => 'Neu Team Eins', 'xteamk1' => 'NE1', 'xteamu1' => 'http://example.org/eins', 'xteamn1' => 'Notiz Eins',
+ 'xstrafp2' => '3', 'xstrafm2' => '1', 'xstrafdat2' => '4',
+ 'xtorkorrektur12' => '2', 'xtorkorrektur22' => '1',
+ ]), $c->changes());
+ $t->add('Mannschaften erneut oeffnen', $c->get('lmoadmin.php', $q), $c->changes());
+ $t->add('oeffentliche Tabelle danach', $c->get('lmo.php', 'file=golden/basic.l98&action=table'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'teams', $t->text());
+ }
+
+ public function testCounts(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $q = self::A . '&todo=edit' . self::BASIC . '&st=-3';
+
+ $t->add('Anzahl Spieltage aendern', $c->submitForm('lmoadmin.php', $q, 'xanzst', ['xanzst' => '15']), $c->changes());
+ $t->add('Anzahl erneut oeffnen', $c->get('lmoadmin.php', $q), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'counts', $t->text());
+ }
+
+ public function testTableCorrection(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $q = self::A . '&todo=tabs&file=golden/handicap.l98&st=3';
+
+ $t->add('Tabellenkorrektur: Platz 1 und 2 tauschen', $c->submitForm('lmoadmin.php', $q, 'xplatz1', [
+ 'xplatz1' => '2', 'xplatz2' => '1',
+ ]), $c->changes());
+ $t->add('oeffentliche Tabelle danach', $c->get('lmo.php', 'file=golden/handicap.l98&action=table'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'table_correction', $t->text());
+ }
+
+ public function testOptions(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+
+ $t->add('Optionen speichern', $c->submitForm('lmoadmin.php', self::A . '&todo=options', 'xadr', [
+ 'xadr' => 'admin@example.test', 'xdeftime' => '18:00', 'xtabpkt' => '0', 'xtabonres' => '2', 'xbacklink' => '0',
+ ]), $c->changes());
+ $t->add('Optionen erneut oeffnen', $c->get('lmoadmin.php', self::A . '&todo=options'), $c->changes());
+ $t->add('oeffentliche Tabelle danach', $c->get('lmo.php', 'file=golden/basic.l98&action=table'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'options', $t->text());
+ }
+
+ public function testDesign(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+
+ $t->add('Design speichern', $c->submitForm('lmoadmin.php', self::A . '&todo=design', 'xlmo_main_background1', [
+ 'xlmo_main_background1' => '#ffeedd', 'xlmo_tabelle_background1' => '#00ff00',
+ ]), $c->changes());
+ $t->add('Stylesheet danach', $c->get('lmo-style.php', ''), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'design', $t->text());
+ }
+
+ public function testUserManagement(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $u = self::A . '&todo=user';
+
+ $t->add('Benutzerliste', $c->get('lmoadmin.php', $u), $c->changes());
+ $t->add('Benutzer anlegen', $c->submitForm('lmoadmin.php', $u, 'xadmin_name', [
+ 'xadmin_name' => 'helfer1', 'xadmin_pass' => 'passwort1',
+ ]), $c->changes());
+ $t->add('Benutzer bearbeiten: Rang, Ligen, erweitert', $c->submitForm('lmoadmin.php', $u . '&show=1', 'xadmin_name1', [
+ 'xadmin_rang1' => '1', 'xhelfer_ligen1[]' => ['basic', 'special'], 'xadmin_erweitert1' => '1',
+ ]), $c->changes());
+ $t->add('Benutzer loeschen', $c->get('lmoadmin.php', $u . '&del=1'), $c->changes());
+ $t->add('Benutzerliste danach', $c->get('lmoadmin.php', $u), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'users', $t->text());
+ }
+
+ public function testDeleteLeague(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+ $d = self::A . '&todo=delete';
+
+ $t->add('Loeschliste', $c->get('lmoadmin.php', $d), $c->changes());
+ $t->add('Liga loeschen', $c->get('lmoadmin.php', $d . '&del=1&dfile=ligen/golden/kegel.l98'), $c->changes());
+ $t->add('nicht vorhandene Datei loeschen', $c->get('lmoadmin.php', $d . '&del=1&dfile=ligen/golden/gibtsnicht.l98'), $c->changes());
+ $t->add('oeffentliche Ligenuebersicht danach', $c->get('lmo.php', ''), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'delete_league', $t->text());
+ }
+
+ public function testCreateLeague(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+
+ $t->add('Schritt 1: Formular', $c->get('lmoadmin.php', self::A . '&todo=new'), $c->changes());
+ $t->add('Schritt 1: Name, Titel, Typ', $c->submitFromLast('xfile', [
+ 'xfile' => 'testliga', 'xtitel' => 'Testliga', 'xtype' => '0',
+ ]), $c->changes());
+ $t->add('Schritt 2: Teams, Spieltage, Spiele', $c->submitFromLast('xteams', [
+ 'xteams' => '4', 'xanzst' => '6', 'xanzsp' => '2',
+ ]), $c->changes());
+ $t->add('Schritt 3: ohne Spielplan', $c->submitFromLast('xprogram', ['xprogram' => 'none']), $c->changes());
+ $t->add('neue Liga bearbeiten', $c->get('lmoadmin.php', self::A . '&todo=edit&file=testliga.l98&st=1'), $c->changes());
+ $t->add('oeffentliche Tabelle der neuen Liga', $c->get('lmo.php', 'file=testliga.l98&action=table'), $c->changes());
+
+ $this->assertMatchesSnapshot('admin-write', 'create_league', $t->text());
+ }
+}
diff --git a/tests/GoldenMaster/CalculationTest.php b/tests/GoldenMaster/CalculationTest.php
new file mode 100644
index 00000000..44de41b2
--- /dev/null
+++ b/tests/GoldenMaster/CalculationTest.php
@@ -0,0 +1,39 @@
+runner()->calc($leagueFile);
+ $json = str_replace($fixture->app()->path(), '{LMO_PATH}', $json);
+
+ $this->assertMatchesSnapshot('calc', $leagueFile, $json . "\n");
+ }
+
+ public static function leagues(): array
+ {
+ $cases = [];
+ foreach (array_keys(Matrix::leagues(Fixture::sourceRoot())) as $file) {
+ $cases[$file] = [$file];
+ }
+ return $cases;
+ }
+}
diff --git a/tests/GoldenMaster/CoreGapsTest.php b/tests/GoldenMaster/CoreGapsTest.php
new file mode 100644
index 00000000..aecba2b1
--- /dev/null
+++ b/tests/GoldenMaster/CoreGapsTest.php
@@ -0,0 +1,60 @@
+runner()->http(['script' => 'index.php', 'query' => 'file=golden/basic.l98&action=table']);
+ $body = str_replace($fixture->app()->path(), '{LMO_PATH}', $result['body']);
+
+ $this->assertMatchesSnapshot('core-gaps', 'index_redirect', $body . "\n");
+ }
+
+ public function testCreateLeagueFromTemplate(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+
+ $t->add('Schritt 1: Formular', $c->get('lmoadmin.php', 'action=admin&todo=new'), $c->changes());
+ $t->add('Schritt 1: Name, Titel, Typ', $c->submitFromLast('xfile', [
+ 'xfile' => 'ausvorlage', 'xtitel' => 'Aus Vorlage', 'xtype' => '0',
+ ]), $c->changes());
+ $t->add('Schritt 2: Teams, Spieltage, Spiele (wie golden/basic)', $c->submitFromLast('xteams', [
+ 'xteams' => '6', 'xanzst' => '10', 'xanzsp' => '3',
+ ]), $c->changes());
+ // $xprogram wird roh an fopen() uebergeben (siehe Klassenkommentar); der Pfad ist relativ
+ // zu PATH_TO_LMO, da dort hin gechdir() wurde.
+ $t->add('Schritt 3: Spielplan aus golden/basic.l98 uebernehmen', $c->submitFromLast('xprogram', [
+ 'xprogram' => 'ligen/golden/basic.l98',
+ ]), $c->changes());
+ $t->add('neue Liga bearbeiten (Spielplan sollte aus der Vorlage stammen)', $c->get('lmoadmin.php', 'action=admin&todo=edit&file=ausvorlage.l98&st=1'), $c->changes());
+ $t->add('oeffentlicher Spielplan der neuen Liga', $c->get('lmo.php', 'file=ausvorlage.l98&action=program'), $c->changes());
+
+ $this->assertMatchesSnapshot('core-gaps', 'create_league_from_template', $t->text());
+ }
+}
diff --git a/tests/GoldenMaster/KoLeagueTest.php b/tests/GoldenMaster/KoLeagueTest.php
new file mode 100644
index 00000000..32370625
--- /dev/null
+++ b/tests/GoldenMaster/KoLeagueTest.php
@@ -0,0 +1,80 @@
+ Team-AUSWAHL statt Freitext, Spielmodus je Runde statt
+ * Spielplan-Optionen). Die Liga wird bewusst ueber den echten Admin-Assistenten erzeugt statt
+ * als Fixture-Datei nachgebaut - das Dateiformat ist komplex genug, dass der "richtige" Weg
+ * ueber die Anwendung selbst zuverlaessiger ist als eine handgebaute Datei.
+ *
+ * Abgedeckt: ein 4er-Turnierbaum (Halbfinale + Finale), Einzelspiel je Runde (Standard-Modus).
+ * NICHT abgedeckt (eigener, groesserer Block, siehe TESTING.md): Hin-/Rueckspiel und
+ * Best-of-3/5/7 (Modus 2/3/5/7), Playoffmode-Varianten (2-2-1 usw.), Spielerstatistik-Addon
+ * fuer KO, Tippspiel-Addon fuer KO (lmo-tippeditko.php und Verwandte).
+ */
+final class KoLeagueTest extends TestCase
+{
+ use AssertsSnapshots;
+
+ private const A = 'action=admin';
+
+ public function testCreateAndPlayOutBracket(): void
+ {
+ $c = Fixture::loggedInClient();
+ $t = new Transcript();
+
+ $t->add('Schritt 1: Name, Titel, Typ=Pokal', $c->submitForm('lmoadmin.php', self::A . '&todo=new', 'xfile', [
+ 'xfile' => 'pokal', 'xtitel' => 'Pokal', 'xtype' => '1',
+ ]), $c->changes());
+ $t->add('Schritt 2: 4 Teams (Turnierbaum-Groesse)', $c->submitFromLast('xteams', ['xteams' => '4']), $c->changes());
+ $t->add('Schritt 3: Spielmodus je Runde (Standard: Einzelspiel)', $c->submitFromLast('xmod1'), $c->changes());
+
+ $t->add('Mannschaftsnamen eintragen', $c->submitForm('lmoadmin.php', self::A . '&todo=edit&file=pokal.l98&st=-2', 'xteams1', [
+ 'xteams1' => 'Rot', 'xteams2' => 'Blau', 'xteams3' => 'Gruen', 'xteams4' => 'Gelb',
+ ]), $c->changes());
+
+ // Halbfinale: Spiel 0 = Rot-Blau, Spiel 1 = Gruen-Gelb (Feldnamen "x...{Spiel}{Durchgang}",
+ // bei Einzelspiel ist der Durchgang immer 0).
+ $t->add('Halbfinale eintragen (Runde 1)', $c->submitForm('lmoadmin.php', self::A . '&todo=edit&file=pokal.l98&st=1', 'xgoala00', [
+ 'xteama0' => '1', 'xteamb0' => '2', 'xgoala00' => '2', 'xgoalb00' => '1',
+ 'xteama1' => '3', 'xteamb1' => '4', 'xgoala10' => '0', 'xgoalb10' => '3',
+ ]), $c->changes());
+ $t->add('Halbfinale erneut oeffnen (Sieger-/Verlierer-Markierung pruefen)', $c->get('lmoadmin.php', self::A . '&todo=edit&file=pokal.l98&st=1'), $c->changes());
+
+ // Finale: Sieger der beiden Halbfinals treten an (Rot aus Spiel 0, Gelb aus Spiel 1).
+ $t->add('Finale eintragen (Runde 2)', $c->submitForm('lmoadmin.php', self::A . '&todo=edit&file=pokal.l98&st=2', 'xgoala00', [
+ 'xteama0' => '1', 'xteamb0' => '4', 'xgoala00' => '3', 'xgoalb00' => '2',
+ ]), $c->changes());
+
+ $t->add('oeffentlicher Spielplan: Rot (Finalsieger)', $c->get('lmo.php', 'file=pokal.l98&action=program&selteam=1'), $c->changes());
+ $t->add('oeffentlicher Spielplan: Blau (im Halbfinale ausgeschieden)', $c->get('lmo.php', 'file=pokal.l98&action=program&selteam=2'), $c->changes());
+ $t->add('oeffentliche Ergebnisse Runde 1 (Halbfinale)', $c->get('lmo.php', 'file=pokal.l98&action=results&st=1'), $c->changes());
+ $t->add('oeffentliche Ergebnisse Runde 2 (Finale)', $c->get('lmo.php', 'file=pokal.l98&action=results&st=2'), $c->changes());
+ $t->add('oeffentlicher Kalender', $c->get('lmo.php', 'file=pokal.l98&action=cal'), $c->changes());
+
+ $this->assertMatchesSnapshot('ko-league', 'create_and_play_out_bracket', $t->text());
+ }
+
+ public function testEditRoundWithoutChanges(): void
+ {
+ $c = Fixture::loggedInClient();
+ $c->submitForm('lmoadmin.php', self::A . '&todo=new', 'xfile', ['xfile' => 'pokal2', 'xtitel' => 'Pokal 2', 'xtype' => '1']);
+ $c->submitFromLast('xteams', ['xteams' => '4']);
+ $c->submitFromLast('xmod1');
+ $c->changes(); // Anlegen nicht Teil des Roundtrip-Protokolls
+
+ $t = new Transcript();
+ $t->add('Runde 1 unveraendert speichern', $c->submitForm('lmoadmin.php', self::A . '&todo=edit&file=pokal2.l98&st=1', 'xgoala00'), $c->changes());
+ $t->add('Mannschaften unveraendert speichern', $c->submitForm('lmoadmin.php', self::A . '&todo=edit&file=pokal2.l98&st=-2', 'xteams1'), $c->changes());
+
+ $this->assertMatchesSnapshot('ko-league', 'edit_round_without_changes', $t->text());
+ }
+}
diff --git a/tests/GoldenMaster/PagesTest.php b/tests/GoldenMaster/PagesTest.php
new file mode 100644
index 00000000..6f874d4f
--- /dev/null
+++ b/tests/GoldenMaster/PagesTest.php
@@ -0,0 +1,36 @@
+runner()->request($query);
+ $normalized = Normalizer::html($html, $fixture->app()->path(), $query);
+
+ $this->assertMatchesSnapshot('pages', $query === '' ? '(Ligenuebersicht)' : $query, $normalized);
+ }
+
+ public static function pages(): array
+ {
+ return Matrix::pages(Fixture::sourceRoot());
+ }
+}
diff --git a/tests/GoldenMaster/RandomScheduleSmokeTest.php b/tests/GoldenMaster/RandomScheduleSmokeTest.php
new file mode 100644
index 00000000..5659a227
--- /dev/null
+++ b/tests/GoldenMaster/RandomScheduleSmokeTest.php
@@ -0,0 +1,90 @@
+';
+
+ public function testRandomScheduleIsGeneratedWithoutUnexpectedErrors(): void
+ {
+ $c = Fixture::loggedInClient();
+
+ $c->get('lmoadmin.php', 'action=admin&todo=new');
+ $c->submitFromLast('xfile', ['xfile' => 'zufallsliga', 'xtitel' => 'Zufallsliga', 'xtype' => '0']);
+ $c->submitFromLast('xteams', ['xteams' => '6', 'xanzst' => '10', 'xanzsp' => '3']);
+ $response = $c->submitFromLast('xprogram', ['xprogram' => 'random']);
+
+ $knownBugFired = strpos($response, self::KNOWN_WARNING) !== false;
+ $response = str_replace(self::KNOWN_WARNING, '', $response);
+
+ $this->assertStringNotContainsString('STDERR', $response, 'Unerwartete PHP-Warnung/Fehler beim Erzeugen des Zufallsspielplans');
+ $this->assertStringNotContainsString('EXIT', $response, 'Abnormaler Abbruch beim Erzeugen des Zufallsspielplans');
+
+ $edit = $c->get('lmoadmin.php', 'action=admin&todo=edit&file=zufallsliga.l98&st=1');
+ $this->assertStringNotContainsString('STDERR', $edit);
+
+ $content = (string)file_get_contents($c->instance()->path() . '/ligen/zufallsliga.l98');
+ $this->assertSchedulePlausibility($content, $knownBugFired);
+ }
+
+ private function assertSchedulePlausibility(string $content, bool $knownBugFired): void
+ {
+ preg_match_all('/^\[Round(\d+)\]$/m', $content, $roundMatches);
+ $this->assertNotEmpty($roundMatches[1], 'Keine Spieltags-Abschnitte in der erzeugten Ligadatei gefunden');
+
+ $sections = preg_split('/^\[Round\d+\]$/m', $content);
+ array_shift($sections); // Teil vor dem ersten [RoundN]
+
+ $brokenDays = [];
+ foreach ($sections as $index => $section) {
+ preg_match_all('/^TA\d+=(\d*)$/m', $section, $home);
+ preg_match_all('/^TB\d+=(\d*)$/m', $section, $away);
+ $teamsOnThisDay = array_merge($home[1], $away[1]);
+ sort($teamsOnThisDay, SORT_STRING);
+ if ($teamsOnThisDay !== ['1', '2', '3', '4', '5', '6']) {
+ $brokenDays[$index + 1] = $teamsOnThisDay;
+ }
+ }
+
+ if ($knownBugFired) {
+ // Der bekannte Fehler ist aufgetreten: wir erwarten (und dokumentieren) einen
+ // kaputten Spielplan, statt den Test daran scheitern zu lassen.
+ $this->assertNotEmpty(
+ $brokenDays,
+ 'Die bekannte Zufalls-Warnung trat auf, aber der Spielplan ist dennoch vollstaendig - ' .
+ 'Annahme ueber die Fehlerwirkung war falsch, bitte Befund pruefen.'
+ );
+ return;
+ }
+
+ $this->assertEmpty(
+ $brokenDays,
+ "Spielplan unvollstaendig, OHNE dass die bekannte Warnung auftrat (Spieltage: " .
+ implode(', ', array_keys($brokenDays)) . ") - das waere ein NEUER Befund."
+ );
+ }
+}
diff --git a/tests/GoldenMaster/StyleTest.php b/tests/GoldenMaster/StyleTest.php
new file mode 100644
index 00000000..26288001
--- /dev/null
+++ b/tests/GoldenMaster/StyleTest.php
@@ -0,0 +1,37 @@
+runner()->http(['script' => $script, 'query' => $query]);
+ $css = str_replace($fixture->app()->path(), '{LMO_PATH}', str_replace("\r\n", "\n", $result['body']));
+
+ $this->assertMatchesSnapshot('style', $script . ($query === '' ? '' : '?' . $query), $css . "\n");
+ }
+
+ public static function endpoints(): array
+ {
+ return [
+ 'lmo-style.php' => ['lmo-style.php', ''],
+ 'lmo-style-nc.php' => ['lmo-style-nc.php', ''],
+ 'lmo-style.php (Englisch)' => ['lmo-style.php', 'lmouserlang=English'],
+ ];
+ }
+}
diff --git a/tests/GoldenMaster/Support/AdminClient.php b/tests/GoldenMaster/Support/AdminClient.php
new file mode 100644
index 00000000..e487adc7
--- /dev/null
+++ b/tests/GoldenMaster/Support/AdminClient.php
@@ -0,0 +1,129 @@
+ */
+ private array $state;
+
+ public function __construct(Instance $instance, Runner $runner)
+ {
+ $this->instance = $instance;
+ $this->runner = $runner;
+ $this->state = StateTracker::read($instance->path());
+ }
+
+ public function get(string $script, string $query = ''): string
+ {
+ return $this->request('GET', $script, $query, []);
+ }
+
+ /** @param array $post */
+ public function post(string $script, string $query, array $post): string
+ {
+ return $this->request('POST', $script, $query, $post);
+ }
+
+ /**
+ * Ruft die Seite ab, fuellt das Formular wie ein Browser aus (Standardwerte + Abweichungen)
+ * und schickt es ab. Rueckgabe: gesendete Felder und normalisierte Antwort.
+ *
+ * @param array> $overrides
+ */
+ public function submitForm(string $script, string $query, ?string $mustContain, array $overrides = [], ?string $submit = null): string
+ {
+ $this->request('GET', $script, $query, []);
+ return $this->submitFromLast($mustContain, $overrides, $submit);
+ }
+
+ /**
+ * Wie submitForm, aber das Formular stammt aus der letzten Antwort (mehrstufige Ablaeufe).
+ *
+ * @param array> $overrides
+ */
+ public function submitFromLast(?string $mustContain, array $overrides = [], ?string $submit = null): string
+ {
+ $form = FormSubmitter::extract($this->lastRaw, $mustContain, $submit);
+ if ($form === null) {
+ return "### KEIN FORMULAR GEFUNDEN (Feld: " . ($mustContain ?? 'save') . ")\n"
+ . "### Antwort der vorigen Seite:\n" . $this->lastNormalized;
+ }
+ if ($form['fields'] === []) {
+ return "### LEERES FORMULAR (Formulare auf der Seite: {$form['formCount']})\n" . $this->lastNormalized;
+ }
+ $fields = FormSubmitter::override($form['fields'], $overrides);
+ $header = "### GESENDETE FELDER (" . strtoupper($form['method']) . ", " . count($fields) . ")\n"
+ . FormSubmitter::describe($fields) . "\n### ANTWORT\n";
+
+ if ($form['method'] === 'post') {
+ return $header . $this->request('POST', $this->lastScript, '', [], FormSubmitter::encode($fields));
+ }
+ return $header . $this->request('GET', $this->lastScript, FormSubmitter::encode($fields), []);
+ }
+
+ public function login(string $user, string $password): string
+ {
+ return $this->post('lmoadmin.php', '', [
+ 'action' => 'admin',
+ 'xusername' => $user,
+ 'xuserpass' => $password,
+ 'xusersub' => 'Login',
+ ]);
+ }
+
+ /** Dateiaenderungen seit dem letzten Aufruf (oder seit Beginn). Leer = nichts geaendert. */
+ public function changes(): string
+ {
+ $now = StateTracker::read($this->instance->path());
+ $report = StateTracker::diff($this->state, $now, $this->instance->path());
+ $this->state = $now;
+ return $report;
+ }
+
+ /** Baseline neu setzen, z. B. nachdem der Test selbst Dateien vorbereitet hat. */
+ public function resetChanges(): void
+ {
+ $this->state = StateTracker::read($this->instance->path());
+ }
+
+ public function instance(): Instance
+ {
+ return $this->instance;
+ }
+
+ /**
+ * @param array $post
+ */
+ private function request(string $method, string $script, string $query, array $post, ?string $postRaw = null): string
+ {
+ $spec = [
+ 'script' => $script,
+ 'query' => $query,
+ 'method' => $method,
+ 'post' => $post,
+ 'sid' => $this->sid,
+ ];
+ if ($postRaw !== null) {
+ $spec['postRaw'] = $postRaw;
+ }
+ $result = $this->runner->http($spec);
+ if ($result['sid'] !== '') {
+ $this->sid = $result['sid'];
+ }
+ $this->lastRaw = $result['body'];
+ $this->lastScript = $script;
+ $this->lastNormalized = Normalizer::admin($result['body'], $this->instance->path(), $query);
+ return $this->lastNormalized;
+ }
+}
diff --git a/tests/GoldenMaster/Support/AdminMatrix.php b/tests/GoldenMaster/Support/AdminMatrix.php
new file mode 100644
index 00000000..c55a25ea
--- /dev/null
+++ b/tests/GoldenMaster/Support/AdminMatrix.php
@@ -0,0 +1,55 @@
+ Label => [Skript, Query]
+ */
+ public static function views(): array
+ {
+ $cases = [];
+ $add = static function (string $query) use (&$cases): void {
+ $cases['lmoadmin.php?' . $query] = ['lmoadmin.php', $query];
+ };
+ $a = 'action=admin';
+
+ foreach (['', 'new', 'open', 'delete', 'upload', 'download', 'options', 'addons', 'design', 'user', 'update',
+ 'tipp', 'tippemail', 'tippuser', 'tippuseredit', 'tippoptions', 'vieweroptions', 'pdfoptions'] as $todo) {
+ $add($a . ($todo === '' ? '' : '&todo=' . $todo));
+ }
+ $add($a . '&todo=open&subdir=golden/');
+ $add($a . '&todo=open&subdir=dfb/');
+
+ foreach (array_merge([self::REAL], self::FULL) as $file) {
+ $f = '&file=' . $file;
+ $add($a . '&todo=edit' . $f);
+ foreach ([1, 7, -1, -2, -3, -10, -4] as $st) {
+ $add($a . '&todo=edit' . $f . '&st=' . $st);
+ }
+ $add($a . '&todo=tabs' . $f);
+ $add($a . '&todo=download' . $f);
+ }
+ foreach (self::BRIEF as $file) {
+ $f = '&file=' . $file;
+ $add($a . '&todo=edit' . $f . '&st=1');
+ $add($a . '&todo=edit' . $f . '&st=-1');
+ $add($a . '&todo=tabs' . $f);
+ }
+ return $cases;
+ }
+}
diff --git a/tests/GoldenMaster/Support/AssertsSnapshots.php b/tests/GoldenMaster/Support/AssertsSnapshots.php
new file mode 100644
index 00000000..1efe355e
--- /dev/null
+++ b/tests/GoldenMaster/Support/AssertsSnapshots.php
@@ -0,0 +1,45 @@
+addToAssertionCount(1);
+ return;
+ }
+
+ if (!is_file($file) && Snapshot::recordingMissing()) {
+ $dir = dirname($file);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0777, true);
+ }
+ file_put_contents($file, '### ' . $name . "\n" . $actual);
+ $this->addToAssertionCount(1);
+ return;
+ }
+
+ if (!is_file($file)) {
+ $this->fail(
+ "Kein Snapshot fuer \"$name\" ($file). " .
+ 'Am unveraenderten Originalstand aufzeichnen: GOLDEN_MODE=record-missing (siehe TESTING.md).'
+ );
+ }
+
+ $this->assertSame(
+ file_get_contents($file),
+ '### ' . $name . "\n" . $actual,
+ "Ausgabe weicht vom Golden Master ab: $name"
+ );
+ }
+}
diff --git a/tests/GoldenMaster/Support/Fixture.php b/tests/GoldenMaster/Support/Fixture.php
new file mode 100644
index 00000000..3ec060ca
--- /dev/null
+++ b/tests/GoldenMaster/Support/Fixture.php
@@ -0,0 +1,105 @@
+app = $app;
+ $this->runner = new Runner($app);
+ }
+
+ public static function get(): self
+ {
+ if (self::$instance === null) {
+ $app = Instance::create(self::sourceRoot());
+ LeagueFactory::writeAll(self::sourceRoot() . '/lmo', $app->ligenDir() . '/golden');
+ $app->freezeLeagueTimes();
+ register_shutdown_function(static function () use ($app): void {
+ $app->destroy();
+ });
+ self::$instance = new self($app);
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Neue, unabhaengige Testinstanz (fuer Tests, die Dateien veraendern).
+ *
+ * @return array{0:Instance,1:Runner}
+ */
+ public static function freshInstance(): array
+ {
+ $app = Instance::create(self::sourceRoot());
+ LeagueFactory::writeAll(self::sourceRoot() . '/lmo', $app->ligenDir() . '/golden');
+ $app->freezeLeagueTimes();
+ register_shutdown_function(static function () use ($app): void {
+ $app->destroy();
+ });
+ return [$app, new Runner($app)];
+ }
+
+ /** Frische Instanz mit angemeldetem Hauptadmin; das Kennwort-Upgrade beim Login zaehlt nicht als Aenderung. */
+ public static function loggedInClient(): AdminClient
+ {
+ [$app, $runner] = self::freshInstance();
+ $client = new AdminClient($app, $runner);
+ $client->login('admin', 'lmo');
+ $client->changes();
+ return $client;
+ }
+
+ /** Frische Instanz ohne Anmeldung. */
+ public static function anonymousClient(): AdminClient
+ {
+ [$app, $runner] = self::freshInstance();
+ return new AdminClient($app, $runner);
+ }
+
+ private static ?AdminClient $admin = null;
+
+ /** Gemeinsamer, bereits als Hauptadmin angemeldeter Client fuer rein lesende Ansichten. */
+ public static function adminClient(): AdminClient
+ {
+ if (self::$admin === null) {
+ [$app, $runner] = self::freshInstance();
+ $client = new AdminClient($app, $runner);
+ $client->login('admin', 'lmo');
+ $client->changes(); // Kennwort-Upgrade beim ersten Login nicht den Ansichten zurechnen
+ self::$admin = $client;
+ }
+ return self::$admin;
+ }
+
+ /** Projektwurzel (enthaelt lmo/, tests/, docker/); hier landet auch coverage/. */
+ public static function projectRoot(): string
+ {
+ return dirname(__DIR__, 3);
+ }
+
+ public static function sourceRoot(): string
+ {
+ $env = getenv('LMO_SOURCE');
+ return rtrim($env !== false && $env !== '' ? $env : self::projectRoot(), '/');
+ }
+
+ public function app(): Instance
+ {
+ return $this->app;
+ }
+
+ public function runner(): Runner
+ {
+ return $this->runner;
+ }
+}
diff --git a/tests/GoldenMaster/Support/FormSubmitter.php b/tests/GoldenMaster/Support/FormSubmitter.php
new file mode 100644
index 00000000..0747ae8f
--- /dev/null
+++ b/tests/GoldenMaster/Support/FormSubmitter.php
@@ -0,0 +1,131 @@
+,formCount:int}|null
+ */
+ public static function extract(string $html, ?string $mustContain = null, ?string $submit = null): ?array
+ {
+ $dom = new \DOMDocument();
+ $previous = libxml_use_internal_errors(true);
+ $dom->loadHTML('' . $html);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ $xpath = new \DOMXPath($dom);
+
+ $forms = $xpath->query('//form');
+ $chosen = null;
+ foreach ($forms as $form) {
+ $probe = $mustContain ?? 'save';
+ if ($xpath->query('.//*[@name="' . $probe . '"]', $form)->length > 0) {
+ $chosen = $form;
+ break;
+ }
+ }
+ if ($chosen === null && $mustContain === null && $forms->length > 0) {
+ $chosen = $forms->item(0);
+ }
+ if ($chosen === null) {
+ return null;
+ }
+
+ $fields = [];
+ foreach ($xpath->query('.//input|.//select|.//textarea', $chosen) as $element) {
+ /** @var \DOMElement $element */
+ $name = $element->getAttribute('name');
+ if ($name === '' || $element->hasAttribute('disabled')) {
+ continue;
+ }
+ switch (strtolower($element->nodeName)) {
+ case 'input':
+ $type = strtolower($element->getAttribute('type') ?: 'text');
+ if (in_array($type, ['checkbox', 'radio'], true)) {
+ if ($element->hasAttribute('checked')) {
+ $fields[] = [$name, $element->hasAttribute('value') ? $element->getAttribute('value') : 'on'];
+ }
+ } elseif ($type === 'submit') {
+ if ($submit !== null && $name === $submit) {
+ $fields[] = [$name, $element->getAttribute('value')];
+ }
+ } elseif (!in_array($type, ['button', 'reset', 'file', 'image'], true)) {
+ $fields[] = [$name, $element->getAttribute('value')];
+ }
+ break;
+ case 'select':
+ $options = $xpath->query('.//option', $element);
+ $selected = [];
+ foreach ($options as $option) {
+ /** @var \DOMElement $option */
+ if ($option->hasAttribute('selected')) {
+ $selected[] = $option->hasAttribute('value') ? $option->getAttribute('value') : trim($option->textContent);
+ }
+ }
+ if (!$selected && !$element->hasAttribute('multiple') && $options->length > 0) {
+ $first = $options->item(0);
+ $selected[] = $first->hasAttribute('value') ? $first->getAttribute('value') : trim($first->textContent);
+ }
+ foreach ($selected as $value) {
+ $fields[] = [$name, $value];
+ }
+ break;
+ case 'textarea':
+ $fields[] = [$name, $element->textContent];
+ break;
+ }
+ }
+
+ return [
+ 'method' => strtolower($chosen->getAttribute('method') ?: 'get'),
+ 'fields' => $fields,
+ 'formCount' => $forms->length,
+ ];
+ }
+
+ /**
+ * Ersetzt oder ergaenzt Felder. Ein Array-Wert erzeugt mehrere Paare (z. B. name[]).
+ *
+ * @param array $fields
+ * @param array> $overrides
+ * @return array
+ */
+ public static function override(array $fields, array $overrides): array
+ {
+ foreach ($overrides as $name => $value) {
+ $fields = array_values(array_filter($fields, static fn ($pair) => $pair[0] !== (string)$name));
+ foreach ((array)$value as $single) {
+ $fields[] = [(string)$name, (string)$single];
+ }
+ }
+ return $fields;
+ }
+
+ /** @param array $fields */
+ public static function encode(array $fields): string
+ {
+ return implode('&', array_map(static fn ($p) => rawurlencode($p[0]) . '=' . rawurlencode($p[1]), $fields));
+ }
+
+ /** Lesbare Liste der gesendeten Felder fuer das Protokoll. @param array $fields */
+ public static function describe(array $fields): string
+ {
+ $lines = [];
+ foreach ($fields as [$name, $value]) {
+ $value = str_replace(["\r", "\n"], ['\\r', '\\n'], $value);
+ $lines[] = $name . '=' . (strlen($value) > 80 ? substr($value, 0, 80) . '...' : $value);
+ }
+ return implode("\n", $lines);
+ }
+}
diff --git a/tests/GoldenMaster/Support/Instance.php b/tests/GoldenMaster/Support/Instance.php
new file mode 100644
index 00000000..e07081a7
--- /dev/null
+++ b/tests/GoldenMaster/Support/Instance.php
@@ -0,0 +1,173 @@
+/lmo, legt die Konfiguration an, die sonst der Installer
+ * schreibt, und fixiert alles, was die Ausgabe zeitabhaengig machen wuerde.
+ * Dadurch laufen die Tests ohne Webserver und ohne Installationsschritt, und
+ * die Quelle (Originalstand oder Refactoring-Stand) bleibt unveraendert.
+ */
+final class Instance
+{
+ /** 2025-01-01 12:00:00 UTC: fester Zeitstempel fuer alle Ligadateien ("Stand"-Anzeige) */
+ public const FIXED_MTIME = 1735732800;
+
+ /** feste URL, damit erzeugte Links in allen Laeufen gleich sind */
+ public const URL = 'http://lmo.test/lmo';
+
+ /** Verzeichnisse der Quelle, die nicht in die Testinstanz kopiert werden */
+ private const SKIP = ['tests', '.git', 'output'];
+
+ private string $path;
+
+ private function __construct(string $path)
+ {
+ $this->path = $path;
+ }
+
+ public static function create(string $sourceRoot): self
+ {
+ $source = rtrim($sourceRoot, '/') . '/lmo';
+ if (!is_file($source . '/init.php')) {
+ throw new RuntimeException(
+ "Keine LMO-Quelle gefunden unter $source (erwartet /lmo/init.php). " .
+ 'LMO_SOURCE pruefen.'
+ );
+ }
+
+ $path = sys_get_temp_dir() . '/lmo-golden-' . getmypid() . '-' . bin2hex(random_bytes(3));
+ self::copyTree($source, $path, true);
+
+ // Was der Installer sonst anlegt: Standardkonfiguration + init-parameters.php
+ if (is_dir($path . '/install/config')) {
+ self::mergeTree($path . '/install/config', $path . '/config');
+ }
+ $cfg = $path . '/config/cfg.txt';
+ if (!is_file($cfg)) {
+ throw new RuntimeException('config/cfg.txt fehlt auch nach dem Zusammenfuehren mit install/config.');
+ }
+ $text = file_get_contents($cfg);
+ // Berechnungszeit ("Seite in 0.0123 Sek.") wuerde jede Ausgabe veraendern.
+ $text = preg_replace('/^calctime=.*$/m', 'calctime=0', $text);
+ file_put_contents($cfg, $text);
+
+ file_put_contents(
+ $path . '/config/init-parameters.php',
+ ""
+ );
+
+ // Was der Installer sonst anlegt bzw. als schreibbar prueft (install/install.php, Liste 777/666):
+ // ohne addon/tipp/lmo-tippauth.txt bricht z. B. die Tippspiel-Benutzerverwaltung mit einem Fatal Error ab.
+ foreach (['addon/tipp/tipps', 'addon/tipp/tipps/auswert', 'addon/tipp/tipps/einsicht',
+ 'addon/tipp/tipps/auswert/vereine', 'addon/spieler/stats', 'config/viewer'] as $dir) {
+ if (!is_dir($path . '/' . $dir)) {
+ mkdir($path . '/' . $dir, 0777, true);
+ }
+ }
+ if (!is_file($path . '/addon/tipp/lmo-tippauth.txt')) {
+ file_put_contents($path . '/addon/tipp/lmo-tippauth.txt', '');
+ }
+
+ foreach (['output', 'ligen/archiv', '.sessions'] as $dir) {
+ if (!is_dir($path . '/' . $dir)) {
+ mkdir($path . '/' . $dir, 0777, true);
+ }
+ }
+
+ $instance = new self($path);
+ $instance->freezeLeagueTimes();
+ return $instance;
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+
+ public function ligenDir(): string
+ {
+ return $this->path . '/ligen';
+ }
+
+ public function sessionDir(): string
+ {
+ return $this->path . '/.sessions';
+ }
+
+ /** Nach dem Anlegen weiterer Ligen aufrufen (Datum "Stand" und Sortierung nach Dateidatum). */
+ public function freezeLeagueTimes(): void
+ {
+ $it = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($this->ligenDir(), \FilesystemIterator::SKIP_DOTS)
+ );
+ foreach ($it as $file) {
+ if ($file->isFile()) {
+ touch($file->getPathname(), self::FIXED_MTIME);
+ }
+ }
+ }
+
+ public function destroy(): void
+ {
+ if (is_dir($this->path)) {
+ self::removeTree($this->path);
+ }
+ }
+
+ private static function copyTree(string $from, string $to, bool $topLevel = false): void
+ {
+ mkdir($to, 0777, true);
+ foreach (scandir($from) as $name) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ if ($topLevel && in_array($name, self::SKIP, true)) {
+ continue;
+ }
+ $src = $from . '/' . $name;
+ $dst = $to . '/' . $name;
+ if (is_dir($src)) {
+ self::copyTree($src, $dst);
+ } else {
+ copy($src, $dst);
+ }
+ }
+ }
+
+ /** Kopiert Dateien aus $from nach $to, ohne vorhandene zu ueberschreiben. */
+ private static function mergeTree(string $from, string $to): void
+ {
+ if (!is_dir($to)) {
+ mkdir($to, 0777, true);
+ }
+ foreach (scandir($from) as $name) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ $src = $from . '/' . $name;
+ $dst = $to . '/' . $name;
+ if (is_dir($src)) {
+ self::mergeTree($src, $dst);
+ } elseif (!file_exists($dst)) {
+ copy($src, $dst);
+ }
+ }
+ }
+
+ private static function removeTree(string $dir): void
+ {
+ foreach (scandir($dir) as $name) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ $p = $dir . '/' . $name;
+ is_dir($p) && !is_link($p) ? self::removeTree($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+}
diff --git a/tests/GoldenMaster/Support/LeagueFactory.php b/tests/GoldenMaster/Support/LeagueFactory.php
new file mode 100644
index 00000000..e72318b5
--- /dev/null
+++ b/tests/GoldenMaster/Support/LeagueFactory.php
@@ -0,0 +1,292 @@
+> Name => Szenario
+ */
+ public static function scenarios(): array
+ {
+ return [
+ 'basic' => [
+ 'teams' => 8, 'played' => 0.6, 'maxGoals' => 4,
+ 'options' => ['MinusPoints' => 1, 'Direct' => 0],
+ ],
+ 'minus2_direct' => [
+ 'teams' => 8, 'played' => 1.0, 'maxGoals' => 2,
+ 'options' => ['MinusPoints' => 2, 'Direct' => 1],
+ ],
+ 'kegel' => [
+ 'teams' => 6, 'played' => 0.8, 'maxGoals' => 9,
+ 'options' => ['Kegel' => 1, 'MinusPoints' => 1],
+ ],
+ 'penalties' => [
+ 'teams' => 8, 'played' => 0.9, 'maxGoals' => 3,
+ 'options' => ['MinusPoints' => 2, 'Direct' => 1],
+ 'teamKeys' => [
+ 1 => ['SP' => 3, 'SM' => 0, 'STDA' => 0],
+ 2 => ['SP' => -2, 'SM' => -1, 'TOR1' => 3, 'TOR2' => 1, 'STDA' => 5],
+ 3 => ['SP' => 6, 'SM' => 6, 'TOR1' => -2, 'TOR2' => 4, 'STDA' => 9],
+ 4 => ['TOR1' => 2, 'TOR2' => 0, 'STDA' => 3],
+ ],
+ ],
+ 'special' => [
+ 'teams' => 6, 'played' => 0.9, 'maxGoals' => 3,
+ 'options' => [
+ 'Spez' => 1, 'XtraS' => 2, 'XtraU' => 1, 'XtraV' => 1,
+ 'SpezS' => 2, 'SpezU' => 2, 'SpezV' => 1,
+ ],
+ 'special' => true,
+ ],
+ 'handicap' => [
+ 'teams' => 6, 'played' => 0.7, 'maxGoals' => 3,
+ 'options' => ['HandS' => 1, 'Actual' => 3],
+ 'handicap' => true,
+ ],
+ 'wertung' => [
+ 'teams' => 8, 'played' => 0.8, 'maxGoals' => 3,
+ 'options' => ['MinusPoints' => 2],
+ 'wertung' => true,
+ ],
+ // Direkter Vergleich (lmo-calctable1.php) mit Kegelwertung, Sonderpunkten, Wertung und Handicap.
+ // Wenige Tore und viele Unentschieden erzeugen Punktgleichheit; nur Tabellenseiten werden geprueft.
+ 'direct_kegel' => [
+ 'teams' => 8, 'played' => 1.0, 'maxGoals' => 1, 'pages' => 'table',
+ 'options' => ['Kegel' => 1, 'Direct' => 1, 'MinusPoints' => 2],
+ ],
+ 'direct_special' => [
+ 'teams' => 6, 'played' => 1.0, 'maxGoals' => 1, 'pages' => 'table',
+ 'options' => [
+ 'Direct' => 1, 'MinusPoints' => 2, 'Spez' => 1,
+ 'XtraS' => 2, 'XtraU' => 1, 'XtraV' => 1, 'SpezS' => 2, 'SpezU' => 2, 'SpezV' => 1,
+ ],
+ 'special' => true,
+ ],
+ 'direct_wertung' => [
+ 'teams' => 8, 'played' => 1.0, 'maxGoals' => 1, 'pages' => 'table',
+ 'options' => ['Direct' => 1, 'MinusPoints' => 2],
+ 'wertung' => true,
+ ],
+ // Wie direct_wertung, aber mit sehr vielen Wertungsspielen (jedes zweite bis dritte Spiel),
+ // damit auch die Gastsicht einer Heim-Wertung im direkten Vergleich vorkommt.
+ 'direct_wertung_many' => [
+ 'teams' => 8, 'played' => 1.0, 'maxGoals' => 1, 'pages' => 'table', 'kindRange' => 4,
+ 'options' => ['Direct' => 1, 'MinusPoints' => 2],
+ 'wertung' => true,
+ ],
+ 'direct_handicap' => [
+ 'teams' => 6, 'played' => 0.8, 'maxGoals' => 1, 'pages' => 'table',
+ 'options' => ['Direct' => 1, 'HandS' => 1, 'Actual' => 4],
+ 'handicap' => true,
+ ],
+ // Anzeigeoptionen der Tabelle: Favoritenteam (fett), Vereins-URL, Teamnotiz im Tooltip.
+ 'viewopts' => [
+ 'teams' => 6, 'played' => 0.8, 'maxGoals' => 3, 'pages' => 'table',
+ 'options' => ['favTeam' => 3, 'urlT' => 1, 'MinusPoints' => 2],
+ 'teamKeys' => [
+ 2 => ['URL' => 'http://example.org/team2'],
+ 3 => ['NOT' => 'Aufsteiger & Pokalsieger'],
+ 4 => ['URL' => 'https://example.org/team4?a=1&b=2', 'NOT' => 'Zweite Notiz'],
+ 5 => ['SP' => 2, 'SM' => 1, 'STDA' => 4, 'NOT' => 'Strafe und Notiz'],
+ ],
+ ],
+ 'rankzones' => [
+ 'teams' => 10, 'played' => 0.9, 'maxGoals' => 4,
+ 'options' => ['Champ' => 1, 'CL' => 2, 'CK' => 1, 'UC' => 1, 'AR' => 1, 'AB' => 2, 'HideDraw' => 1],
+ ],
+ ];
+ }
+
+ /** Welche Seiten je Szenario geprueft werden: "full" (Standard) oder "table" (nur Tabelle). */
+ public static function pageMode(string $name): string
+ {
+ return self::scenarios()[$name]['pages'] ?? 'full';
+ }
+
+ /** Liga-Datei (relativ zu ligen/) => Anzahl Spieltage, fuer die Seitenmatrix. */
+ public static function files(): array
+ {
+ $files = [];
+ foreach (self::scenarios() as $name => $s) {
+ $files['golden/' . $name . '.l98'] = 2 * ($s['teams'] - 1);
+ }
+ return $files;
+ }
+
+ public static function writeAll(string $sourceLmoDir, string $targetDir): void
+ {
+ if (!is_dir($targetDir)) {
+ mkdir($targetDir, 0777, true);
+ }
+ foreach (self::scenarios() as $name => $scenario) {
+ $template = sprintf('%s/ligen/dfb/%02der-liga.l98', $sourceLmoDir, $scenario['teams']);
+ if (!is_file($template)) {
+ throw new RuntimeException("Vorlage fehlt: $template");
+ }
+ file_put_contents(
+ $targetDir . '/' . $name . '.l98',
+ self::build($name, $scenario, (string)file_get_contents($template))
+ );
+ }
+ }
+
+ private static function build(string $name, array $scenario, string $content): string
+ {
+ $eol = strpos($content, "\r\n") !== false ? "\r\n" : "\n";
+ $lines = preg_split('/\r\n|\n|\r/', $content);
+
+ $teams = $scenario['teams'];
+ $rounds = 2 * ($teams - 1);
+ $matches = intdiv($teams, 2);
+ $results = self::results($name, $scenario, $rounds, $matches);
+
+ $options = ['Name' => $name] + ($scenario['options'] ?? []);
+ $section = '';
+ $seen = [];
+ $out = [];
+
+ $flush = static function (string $closing) use (&$out, &$seen, &$options, $scenario): void {
+ // Fehlende Schluessel am Ende der Sektion nachtragen.
+ if ($closing === 'Options') {
+ foreach ($options as $key => $value) {
+ if (!isset($seen[$key])) {
+ $out[] = $key . '=' . $value;
+ }
+ }
+ } elseif (preg_match('/^Team(\d+)$/', $closing, $m)) {
+ foreach (($scenario['teamKeys'][(int)$m[1]] ?? []) as $key => $value) {
+ if (!isset($seen[$key])) {
+ $out[] = $key . '=' . $value;
+ }
+ }
+ }
+ $seen = [];
+ };
+
+ foreach ($lines as $line) {
+ if (preg_match('/^\[(.+)\]$/', $line, $m)) {
+ $flush($section);
+ $section = $m[1];
+ $out[] = $line;
+ if (preg_match('/^Round(\d+)$/', $section, $r)) {
+ foreach (self::roundExtras($scenario, (int)$r[1], $matches, $results) as $extra) {
+ $out[] = $extra;
+ }
+ }
+ continue;
+ }
+
+ if ($section !== '' && strpos($line, '=') !== false) {
+ [$key, $value] = explode('=', $line, 2);
+ $seen[$key] = true;
+
+ if ($section === 'Options' && array_key_exists($key, $options)) {
+ $value = (string)$options[$key];
+ } elseif (preg_match('/^Team(\d+)$/', $section, $t)
+ && isset($scenario['teamKeys'][(int)$t[1]][$key])) {
+ $value = (string)$scenario['teamKeys'][(int)$t[1]][$key];
+ } elseif (preg_match('/^Round(\d+)$/', $section, $r)
+ && preg_match('/^(GA|GB)(\d+)$/', $key, $g)) {
+ $entry = $results[(int)$r[1]][(int)$g[2]] ?? null;
+ if ($entry !== null) {
+ $value = $entry[$g[1]];
+ }
+ }
+ $out[] = $key . '=' . $value;
+ continue;
+ }
+
+ $out[] = $line;
+ }
+ $flush($section);
+
+ return implode($eol, $out);
+ }
+
+ /**
+ * Ergebnisse je Spieltag und Spiel (1-basiert). Nicht gespielte Spiele fehlen.
+ *
+ * @return array>>
+ */
+ private static function results(string $name, array $scenario, int $rounds, int $matches): array
+ {
+ mt_srand(self::SEED + (int)sprintf('%u', crc32($name)) % 1000003);
+ $results = [];
+ for ($round = 1; $round <= $rounds; $round++) {
+ for ($match = 1; $match <= $matches; $match++) {
+ $played = (mt_rand(0, 999) / 1000) < $scenario['played'];
+ $homeGoals = mt_rand(0, $scenario['maxGoals']);
+ $awayGoals = mt_rand(0, $scenario['maxGoals']);
+ $kind = mt_rand(0, $scenario['kindRange'] ?? 11);
+ $special = mt_rand(0, 2);
+ if (!$played) {
+ continue;
+ }
+ $entry = ['GA' => (string)$homeGoals, 'GB' => (string)$awayGoals];
+ if (!empty($scenario['wertung'])) {
+ // -2 = Spiel am gruenen Tisch gewertet, ET=3 = nur Tore, ohne Wertung
+ if ($kind === 0) {
+ $entry = ['GA' => '-2', 'GB' => '0'];
+ } elseif ($kind === 1) {
+ $entry = ['GA' => '0', 'GB' => '-2'];
+ } elseif ($kind === 2) {
+ $entry['ET'] = '3';
+ }
+ }
+ if (!empty($scenario['special'])) {
+ $entry['SP'] = (string)$special;
+ }
+ $results[$round][$match] = $entry;
+ }
+ }
+ return $results;
+ }
+
+ /**
+ * Zusaetzliche Zeilen direkt hinter [RoundN]: Handicap-Reihenfolge, Sonderwertung, Zusatzmarker.
+ *
+ * @return string[]
+ */
+ private static function roundExtras(array $scenario, int $round, int $matches, array $results): array
+ {
+ $extra = [];
+ if (!empty($scenario['handicap'])) {
+ $ranks = range(1, $scenario['teams']);
+ // deterministische, spieltagsabhaengige Reihenfolge
+ for ($i = 0; $i < $round % $scenario['teams']; $i++) {
+ $ranks[] = array_shift($ranks);
+ }
+ $extra[] = 'HS=' . implode('', array_map(static function ($rank) {
+ return sprintf('%02d', $rank);
+ }, $ranks));
+ }
+ for ($match = 1; $match <= $matches; $match++) {
+ $entry = $results[$round][$match] ?? null;
+ if ($entry === null) {
+ continue;
+ }
+ if (isset($entry['SP'])) {
+ $extra[] = 'SP' . $match . '=' . $entry['SP'];
+ }
+ if (isset($entry['ET'])) {
+ $extra[] = 'ET' . $match . '=' . $entry['ET'];
+ }
+ }
+ return $extra;
+ }
+}
diff --git a/tests/GoldenMaster/Support/Matrix.php b/tests/GoldenMaster/Support/Matrix.php
new file mode 100644
index 00000000..1728c51e
--- /dev/null
+++ b/tests/GoldenMaster/Support/Matrix.php
@@ -0,0 +1,124 @@
+ Label => [Query]
+ */
+ public static function pages(string $sourceRoot): array
+ {
+ $cases = [];
+ $add = static function (string $query) use (&$cases): void {
+ $cases[$query === '' ? '(Ligenuebersicht)' : $query] = [$query];
+ };
+
+ $add('');
+ $add('lmouserlang=English');
+
+ foreach (self::leagues($sourceRoot) as $file => $meta) {
+ $rounds = $meta['rounds'];
+ $mid = max(1, intdiv($rounds, 2));
+ $f = 'file=' . $file;
+
+ if ($meta['tableOnly']) {
+ // Szenarien fuer die Tabellenberechnung: nur Tabellenansichten
+ $add($f . '&action=table');
+ foreach ([1, 2, 3, 4] as $tabType) {
+ $add($f . '&action=table&tabtype=' . $tabType);
+ }
+ $add($f . '&action=table&endtab=' . $mid);
+ $add($f . '&action=table&tabonres=2');
+ $add($f . '&action=table&tabonres=2&tabtype=2');
+ $add($f . '&action=table&tabpkt=0');
+ continue;
+ }
+
+ $add($f);
+ $add($f . '&action=table');
+ $add($f . '&action=results&st=1');
+ $add($f . '&action=program');
+ $add($f . '&action=cross');
+ $add($f . '&action=stats');
+
+ if (!$meta['full']) {
+ continue;
+ }
+
+ foreach ([1, 2, 3, 4] as $tabType) {
+ $add($f . '&action=table&tabtype=' . $tabType);
+ }
+ $add($f . '&action=table&endtab=' . $mid);
+ $add($f . '&action=table&st=' . $mid);
+ // Diese Werte sind Konfigurationsoptionen. Sie lassen sich per GET ueberschreiben,
+ // weil init.php $_GET nach dem Laden der Konfiguration in den globalen Scope extrahiert.
+ $add($f . '&action=table&tabonres=2');
+ $add($f . '&action=table&tabpkt=0');
+ $add($f . '&action=table&tabonres=2&tabpkt=0&tabtype=3');
+ // Statistik: ohne stat1 zeigt die Seite nur die Teamliste; Vergleiche kommen erst mit stat1/stat2
+ $add($f . '&action=stats&stat1=0&stat2=2'); // stat2 ohne stat1 rutscht nach stat1
+ $add($f . '&action=stats&stat1=1');
+ $add($f . '&action=stats&stat1=1&stat2=2');
+ $add($f . '&action=stats&stat1=2&stat2=1&tabtype=1');
+ $add($f . '&action=results&st=' . $mid);
+ $add($f . '&action=results&st=' . $rounds);
+ $add($f . '&action=program&selteam=1');
+ $add($f . '&action=graph');
+ $add($f . '&action=cal');
+ $add($f . '&action=info');
+ $add($f . '&action=table&lmouserlang=English');
+ }
+ return $cases;
+ }
+
+ /**
+ * Alle Ligadateien mit Berechnung: Name (relativ zu ligen/) => [rounds, full, tableOnly].
+ *
+ * @return array
+ */
+ public static function leagues(string $sourceRoot): array
+ {
+ $leagues = [];
+
+ $dir = $sourceRoot . '/lmo/ligen';
+ if (is_dir($dir)) {
+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
+ foreach ($it as $info) {
+ if (!$info->isFile() || substr($info->getFilename(), -4) !== '.l98') {
+ continue;
+ }
+ $relative = ltrim(str_replace('\\', '/', substr($info->getPathname(), strlen($dir))), '/');
+ $head = (string)file_get_contents($info->getPathname(), false, null, 0, 2000);
+ $type = preg_match('/^Type=(\d+)/m', $head, $m) ? (int)$m[1] : 0;
+ if ($type !== 0) {
+ continue; // KO-Ligen haben eine andere Datenstruktur und sind noch nicht abgedeckt
+ }
+ $rounds = preg_match('/^Rounds=(\d+)/m', $head, $m) ? (int)$m[1] : 1;
+ $leagues[$relative] = [
+ 'rounds' => $rounds,
+ 'full' => in_array($relative, self::FULL_REAL, true),
+ 'tableOnly' => false,
+ ];
+ }
+ }
+
+ foreach (LeagueFactory::files() as $file => $rounds) {
+ $name = basename($file, '.l98');
+ $tableOnly = LeagueFactory::pageMode($name) === 'table';
+ $leagues[$file] = ['rounds' => $rounds, 'full' => !$tableOnly, 'tableOnly' => $tableOnly];
+ }
+
+ ksort($leagues);
+ return $leagues;
+ }
+}
diff --git a/tests/GoldenMaster/Support/Normalizer.php b/tests/GoldenMaster/Support/Normalizer.php
new file mode 100644
index 00000000..7883fb9c
--- /dev/null
+++ b/tests/GoldenMaster/Support/Normalizer.php
@@ -0,0 +1,61 @@
+: 0.0123 sek.") durch einen Platzhalter ersetzt.
+ */
+ public static function admin(string $html, string $instancePath, string $query = ''): string
+ {
+ $html = preg_replace('/(