diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 73ecd89..8eece73 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,11 @@ ## Checkliste - [ ] `dotnet test tools/Nova.SimRunner.Tests` lokal grün +- [ ] **Unity-Spuren:** EditMode N/N, PlayMode M/M — oder „nicht gelaufen“ mit Grund. + Pflichtzeile, wenn der PR `Assets/_Project/Scripts/{Simulation,Core,AI,AI.Data,Data,Gameplay,Presentation}/`, + `Assets/_Project/Editor/`, `Assets/_Project/Scenes/` oder `Assets/Tests/` berührt; + sonst „nicht betroffen“. Kommando: `python3 tools/unity-tests/run_unity_tests.py all` + (siehe [CONTRIBUTING.md](../CONTRIBUTING.md) §3) - [ ] Zeile unter `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) - [ ] Echte Entscheidung getroffen? → D-ID im [DecisionLog](../docs/production/DecisionLog.md), sonst streichen - [ ] Bei Simulationsänderung: keine Determinismus-Baseline im selben PR geändert diff --git a/AGENTS.md b/AGENTS.md index 7c2d6bd..c4b2678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md – Arbeitsregeln für KI-Agenten & Mitwirkende -**Dokumentversion:** 5.0.0 | **Status:** verbindlich | **Verantwortungsbereich:** Project Owner | **Sprint:** 16 | **Governance-Tier:** 2 ([GOVERNANCE.md](GOVERNANCE.md)) +**Dokumentversion:** 5.1.0 | **Status:** verbindlich | **Verantwortungsbereich:** Project Owner | **Sprint:** 16 | **Governance-Tier:** 2 ([GOVERNANCE.md](GOVERNANCE.md)) Verbindliche Betriebsanleitung für jeden KI-Coding-Agenten (Claude, Kimi, Codex, Cursor u. a.) und jede Person, die an *Hashkrieg* arbeitet. **Lies diese Datei @@ -57,6 +57,9 @@ Zusätzlich, wenn zutreffend: stattdessen „nicht gespielt“, Grund, automatisierte Ersatznachweise und Restrisiko nennt. Der PR ist dann mergebar, aber nicht spielerisch abgenommen und kein Meilenstein-Nachweis. +- Unity-Auslöser berührt (§4)? → beide Unity-Spuren lokal fahren und die Zeile + „Unity-Spuren“ im PR ausfüllen; „nicht gelaufen“ samt Grund ist erlaubt, muss + aber dastehen - Neues oder entferntes Dokument? → [docs/README.md](docs/README.md)-Index nachziehen - Inhaber-PR? → Selbst-Merge erst nach grüner Pflicht-CI und dokumentiertem, unabhängigem Read-only-Review. @@ -81,9 +84,27 @@ im PATH liegt: export DOTNET_ROOT="$PWD/.dotnet"; export PATH="$DOTNET_ROOT:$PATH" ``` -Die CI führt bei jedem PR die Simulationstests aus. Unity-EditMode-Tests laufen -mangels CI-Lizenz nur lokal – wer die Präsentationsschicht anfasst, führt sie aus -und schreibt das Ergebnis in den PR. +Die CI führt bei jedem PR die Simulationstests aus. Die Unity-Spuren (EditMode +und PlayMode unter `Assets/Tests/`) laufen in keiner CI, sondern **lokal vor dem +Merge** (D-125). Auslöser ist jede Änderung unter +`Assets/_Project/Scripts/{Simulation,Core,AI,AI.Data,Data,Gameplay,Presentation}/`, +`Assets/_Project/Editor/`, `Assets/_Project/Scenes/` oder `Assets/Tests/` – +auch eine reine Simulationsänderung kann einen PlayMode-Test brechen (#141). +Zuständig ist, wer den PR vorbereitet (Orchestrator bei der Sprint-Integration), +und der Inhaber prüft die Zeile „Unity-Spuren“ beim Merge. Ein Agent in einer +Umgebung ohne Unity meldet „nicht gelaufen“ samt Grund. + +```bash +python3 tools/unity-tests/run_unity_tests.py all # EditMode, dann PlayMode +python3 tools/unity-tests/run_unity_tests.py --self-test +``` + +Das Skript nimmt die Editor-Version aus `ProjectSettings/ProjectVersion.txt`, +bricht bei offenem Editor ab, setzt **nie `-quit`** (Unity beendet sich sonst vor +dem Testlauf, schreibt keine Ergebnisdatei und meldet trotzdem Erfolg), lässt +PlayMode mit Grafik laufen und prüft Ergebnisdatei, Frische, Testanzahl und +Fehlschläge. Es warnt außerdem vor Resten im Arbeitsbaum (`ProjectSettings.asset`, +`InitTestScene*`). Die Zahlen gibt es aus; sie stehen nirgends fest in der Doku. ## 5. Repository-Struktur @@ -170,3 +191,4 @@ gh pr create --fill --base main | 4.0.0 | 2026-08-06 | D-076: auf Governance-Tier 1 zurückgeschnitten. Gate-Kette, Receipt-Verträge und Evidenzpflicht schlafen gelegt; DoD von 13 auf 4 Punkte; Doku-Ritual freiwillig; Sprint-Ritual entfernt; `dotnet test` als kanonischer CI-Check verankert | Orchestrator | | 4.1.0 | 2026-08-08 | D-091: Governance-Tier 2 für externe Beiträge aktiviert; Maintainer-Review/CLA für fremde PRs und Versionspflicht für Verträge/öffentliche Doku ergänzt | Dennis Westermann / Michael Falk | | 5.0.0 | 2026-08-10 | D-105: Dennis Westermann als alleinigen Projektinhaber, Maintainer, Tier-Entscheider und Mergeberechtigten festgelegt; Selbst-Merge bei grüner Pflicht-CI und unabhängigem Review sowie ehrliche Spielabnahme-Zurückstellung geregelt | Dennis Westermann | +| 5.1.0 | 2026-10-17 | D-125 (Entwurf): Unity-Spuren als lokale Pflichtspur mit Skript, Auslöserliste, Zuständigkeit und Nachweiszeile im PR; DoD §3 und Testabschnitt §4 ergänzt (#110) | Orchestrator | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af38f4..9d06571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,18 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Unity-Spuren sind eine lokale Pflichtspur mit Skript, Auslöserliste und + Nachweiszeile im PR (D-125, Entwurf; Refs #110).** EditMode und PlayMode laufen + in keiner CI und haben das schon mehrfach gekostet (#110, #141, #143). Das neue + `tools/unity-tests/run_unity_tests.py` fährt beide Spuren in einem Kommando: + nie mit `-quit`, `-nographics` nur für EditMode, Abbruch bei offenem Editor, + alte Ergebnisdatei vorher gelöscht, danach Prüfung auf Frische, Testanzahl und + Fehlschläge, dazu eine Warnung vor Resten im Arbeitsbaum. Der Auslöser ist + breiter als bisher (auch Simulation, Editor, Szenen und Tests, nicht nur + Präsentation und Gameplay). `AGENTS.md` und `CONTRIBUTING.md` (beide 5.1.0) + und die PR-Vorlage (Pflichtzeile „Unity-Spuren“, „nicht gelaufen“ mit Grund + bleibt erlaubt) ziehen nach. `tests.yml` bleibt unverändert; eine CI-Aufnahme + nur für EditMode ist mit Wiedervorlage-Bedingungen vertagt. - **Die KI spielt nicht mehr jede Partie gleich (D-114, Stufe A).** Bisher lief jede lokale Partie identisch: Die KI würfelt bewusst nie, und das Menü startete immer mit demselben Seed. Jetzt zieht „Neues Spiel“ und „Neue Runde“ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3859cf5..80562a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Beitragen zu Hashkrieg -**Version:** 5.0.0 | **Status:** verbindlich | **Verantwortungsbereich:** Project Owner | **Sprint:** 16 | **Governance-Tier:** 2 ([GOVERNANCE.md](GOVERNANCE.md)) +**Version:** 5.1.0 | **Status:** verbindlich | **Verantwortungsbereich:** Project Owner | **Sprint:** 16 | **Governance-Tier:** 2 ([GOVERNANCE.md](GOVERNANCE.md)) Branch-, PR- und Review-Ablauf für Menschen und KI-Agenten. Detailregeln stehen in [AGENTS.md](AGENTS.md), Dokumentregeln in @@ -57,9 +57,24 @@ Pflicht auf jedem PR: CLA-Zustimmung und die Freigabe des Projektinhabers auf dem aktuellen Head vorliegen. -Unity-EditMode-Tests laufen mangels CI-Lizenz nicht automatisch. Wer die -Präsentationsschicht (`Assets/_Project/Scripts/{Presentation,Gameplay}`) anfasst, -führt sie lokal aus und schreibt das Ergebnis in den PR. +**Unity-Spuren – lokal, vor dem Merge (D-125).** Die Unity-Tests (EditMode und +PlayMode unter `Assets/Tests/`) laufen in keiner CI; es gibt dort keine +Unity-Lizenz. Wer einen PR vorbereitet, der +`Assets/_Project/Scripts/{Simulation,Core,AI,AI.Data,Data,Gameplay,Presentation}/`, +`Assets/_Project/Editor/`, `Assets/_Project/Scenes/` oder `Assets/Tests/` +berührt, fährt beide Spuren selbst – auch bei einer reinen Simulationsänderung, +die einen PlayMode-Test brechen kann (#141): + +```bash +python3 tools/unity-tests/run_unity_tests.py all +``` + +Das Skript setzt nie `-quit` (Unity beendet sich damit vor dem Testlauf und +meldet trotzdem Erfolg), bricht bei offenem Editor ab, lässt PlayMode mit Grafik +laufen und prüft Ergebnisdatei, Frische, Testanzahl und Fehlschläge. Das +Ergebnis kommt in die Zeile „Unity-Spuren“ der PR-Vorlage. „Nicht gelaufen“ +samt Grund ist erlaubt, muss aber dastehen; der Inhaber sieht es beim Merge. +Wer Unity nicht hat, sagt das, statt es zu überspringen. ## 4. Zugänge und Reviews (Tier 2) @@ -156,3 +171,4 @@ sind keine Game-Releases. Es gibt bisher kein veröffentlichtes Release. | 4.0.0 | 2026-08-08 | D-091: Tier 2 aktiviert; Maintainer-Peer-Review auf aktuellem Head für jeden PR, Fork-only plus CLA für externe Beiträge, Baseline-Wächter und zwei festgelegte Merge-Accounts dokumentiert | Dennis Westermann / Michael Falk | | 4.0.1 | 2026-08-08 | CLA-Wirkung nicht rückwirkend präzisiert und D-ID-Pflicht auf echte Entscheidungen vereinheitlicht | Dennis Westermann | | 5.0.0 | 2026-08-10 | D-105: `@cubetribe` als alleinigen Projektinhaber und Mergeberechtigten festgelegt; Inhaber-Selbst-Merge, externe aktuelle Freigabe und ehrliche Spielabnahme-Zurückstellung geregelt | Dennis Westermann | +| 5.1.0 | 2026-10-17 | D-125 (Entwurf): Unity-Spuren als lokale Pflichtspur mit Skript, Auslöserliste und Nachweiszeile im PR (#110) | Orchestrator | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index c00a15c..c9a2ae5 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -3662,6 +3662,138 @@ berührt. Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt diesen, nicht r8. +### D-125 | Entwurf | Unity-Spuren als lokale Pflichtspur (kein CI-Lauf) + +**Status:** Entwurf aus dem Issue-Lauf vom 2026-10-17 (#110); verbindlich mit dem +Merge des PR. Die Endnummer vergibt der Integrator. + +**Kontext:** `.github/workflows/tests.yml` fährt ausschließlich +`dotnet test tools/Nova.SimRunner.Tests`. Die zwei Unity-Spuren unter +`Assets/Tests/` (EditMode, PlayMode) laufen in keiner CI, weil es dort keine +Unity-Lizenz gibt. Der Zustand war weder verboten noch geregelt, und er hat +mehrfach gekostet: + +1. **#110:** `MainMenuTests.NetworkPanel_ValidatesMasksAndCancelsWithoutStartingGameplay` + war auf `main` rot, und niemand hat es bemerkt. Die Erwartung war seit + Sprint 14 (D-092) überholt; sie wurde mit `e3149f9` berichtigt. +2. **#141 (`e4faa75`):** ein Wirtschaftsfix, der nur `Scripts/Simulation/Economy/` + und Tests anfasste, machte `GrayboxDemoProofTests` rot. Die headless-Kette war + mit 754/754 grün, der PR wurde gemergt. Die bisherige Regel in + [CONTRIBUTING.md](../../CONTRIBUTING.md) und [AGENTS.md](../../AGENTS.md) + verlangte den Unity-Lauf nur bei Änderungen an Präsentation und Gameplay und + nannte nur EditMode. +3. **#143 (`85c912b`):** die Reparatur dazu. Aufgefallen war es nur, weil der + Orchestrator vor dem Bauen von Hand `-runTests -testPlatform PlayMode` + gefahren hat. Danach meldete er EditMode 663/663 und PlayMode 15/15. +4. **#144 (`a6858d1`)** änderte `MainMenuController.cs` und zwei `.asmdef` ohne + erwähnten Unity-Lauf. + +Treffender als „läuft nirgends“ ist also: **Die Spuren laufen sporadisch, ohne +Pflicht, ohne Auslöser und ohne Zuständigen.** Dazu kommt eine Falle, die nur +in Test-Docstrings und im Graybox-Log stand: `-quit` zusammen mit `-runTests` +beendet Unity vor dem Testlauf, schreibt keine Ergebnisdatei und meldet +trotzdem Erfolg; das hat in Sprint 21 einen grünen Eindruck erzeugt, den es nie +gab. PlayMode braucht außerdem ein Grafikgerät (kein `-nographics`; +Screenshot-Capture und UI Toolkit rendern, vgl. +[GrayboxLog.md](GrayboxLog.md), `GrayboxDemoProofTests`). + +Die Empfehlung des Arbeiters aus Sprint 21 (Weg A, GameCI, „EditMode und +PlayMode beide headless“) liegt nur außerhalb des Repos und ist in einem Punkt +**falsch**: PlayMode läuft nicht ohne Grafik. Sie wird hier ausdrücklich +verworfen, nicht stillschweigend übergangen. + +**Alternativen:** + +1. **A – GameCI `unity-test-runner` in GitHub Actions.** Ein neuer Workflow + fährt Unity `6000.5.4f1` im Docker-Image mit einem Lizenz-Secret. + - *Für:* schützt auch gegen vergessene Läufe; ein roter Check ist sichtbar. + - *Gegen:* ein Lizenz-Secret im Repo-Kontext und ein neuer Workflow sind eine + Sicherheitsfläche mit eigener Prüfung (`@ci-security-guardian`, + `@security`). Fork-PRs bekommen keine Secrets, der Check liefe gerade für + externe Beiträge nicht. Ob Unity Personal in CI zulässig ist und ob ein + Image für `6000.5.4f1` existiert, ist unverifiziert. PlayMode bräuchte + xvfb und Software-Rendering, ob die UI-Toolkit-Tests so laufen, ist + unverifiziert. Ein flackernder Pflichtcheck blockiert den alleinigen + Mergenden. Aufwand L bis XL. +2. **B – selbstgehosteter Runner auf dem Rechner des Inhabers.** Echte GPU, kaum + Kosten. *Gegen:* das Repository ist öffentlich und nimmt Fork-PRs an + (`external-contributor-review`). Der Runner würde fremden Code auf dem + Inhaberrechner ausführen; GitHub rät bei öffentlichen Repositories davon ab. + **Verworfen.** +3. **C – ausdrücklich „nur lokal, vor dem Merge“, mit Skript, Auslöserliste, + Zuständigem und Nachweiszeile im PR.** Keine Secrets, keine + Workflow-Änderung. *Gegen:* nichts erzwingt den Lauf; die Regel trägt nur, + wenn der Zuständige sie fährt. Die Zeile im PR macht ein Auslassen sichtbar, + nicht unmöglich. Aufwand M. +4. **Status quo** (nur der Satz „laufen mangels CI-Lizenz nur lokal“). Er hat + #141 nicht verhindert. **Verworfen.** + +**Entscheidung:** **C**, mit vertagter Wiedervorlage von **A nur für +EditMode**. + +- `tools/unity-tests/run_unity_tests.py` (`edit | play | all`, `--self-test`) ist + das eine Kommando: + - Editor-Version aus `ProjectSettings/ProjectVersion.txt`; + - Abbruch bei offenem Editor (`Library/EditorInstance.json`, Prozess lebt); + - **nie `-quit`**; `-nographics` nur für EditMode; + - alte Ergebnis-XML wird vorher gelöscht; + - danach Prüfung von Existenz, Frische, Testanzahl größer null und + `failed == 0`. Eine fehlende, leere, veraltete, kaputte oder rote Datei ist + Exit 1, ein Unity-Exitcode ungleich null ebenso; + - `git status` vor und nach dem Lauf wird verglichen und warnt vor Resten + (`ProjectSettings.asset`, `InitTestScene*`); das Skript räumt nichts auf. +- **Auslöser** ist jede Änderung unter + `Assets/_Project/Scripts/{Simulation,Core,AI,AI.Data,Data,Gameplay,Presentation}/`, + `Assets/_Project/Editor/`, `Assets/_Project/Scenes/` und `Assets/Tests/`, nicht + mehr nur Präsentation und Gameplay (Beleg: #141). +- **Zuständig** ist, wer den PR vorbereitet (bei der Sprint-Integration der + Orchestrator); der Inhaber prüft die Zeile beim Merge. Ein Agent in einer + Umgebung ohne Unity (isolierter Worktree, fremde Maschine) fährt die Spur + nicht und meldet „nicht gelaufen“. +- **Nachweis:** Die PR-Vorlage trägt die Pflichtzeile „Unity-Spuren: EditMode + N/N, PlayMode M/M — oder nicht gelaufen mit Grund“. „Nicht gelaufen“ bleibt + erlaubt, muss aber dastehen (analog zur ehrlichen Spielabnahme-Zurückstellung + aus D-105). +- **Keine Testanzahlen** in `AGENTS.md`, `CONTRIBUTING.md` oder der Vorlage; + das Skript gibt sie aus. +- `tests.yml` bleibt unangetastet. Auch der `--self-test` des Skripts läuft + nicht in der CI: ihn dort einzuhängen, hieße `.github/workflows/` ändern. Er + läuft von Hand mit dem Skript und ist ein Kandidat für die Wiedervorlage. +- **Wiedervorlage von A (nur EditMode, `-nographics`)**, sobald eine dieser + Bedingungen eintritt: (a) externe PRs werden wieder erwartet (der letzte + externe Beitragende ist seit 2026-08-21 inaktiv); (b) der Inhaber hat geklärt, + dass Unity Personal in CI zulässig ist, und ein Lizenzweg steht; (c) trotz + Pflichtzeile geht ein weiterer rot gemergter Unity-Test auf `main`. PlayMode + bleibt dauerhaft lokal. + +**Begründung:** Das Projekt hat faktisch einen Entwickler und einen +Orchestrator auf dem Inhaberrechner, auf dem Unity liegt. Eine CI für PlayMode +ist ohne Grafikgerät unverifiziert, ein Lizenz-Secret plus Workflow ist eine +Sicherheitsfläche, und ein Runner auf dem Inhaberrechner (B) ist bei einem +öffentlichen Repository mit Fork-PRs zu gefährlich. Was sich heute zuverlässig +beheben lässt, ist der Zwischenzustand: ein Kommando, das nicht lügen kann +(kein `-quit`, Ergebnisdatei erzwungen), ein Auslöser, der #141 erfasst hätte, +ein Name und eine Zeile im PR. Was engine-frei ist, gehört weiterhin in die +`dotnet`-Kette (Vorbild: der Geländespiegel aus #126). + +**Konsequenzen:** + +- **Die Regel erzwingt nichts.** Wer die Zeile ehrlich mit „nicht gelaufen“ + füllt, darf mergen lassen. Der Schutz hängt an der Prüfung des Inhabers beim + Merge. Das ist bewusst: es ist die Ehrlichkeitsregel aus + [AGENTS.md](../../AGENTS.md) Regel 5, angewandt auf Unity. +- **Das Skript läuft nur auf Rechnern mit Unity `6000.5.4f1`.** Worker in + isolierten Worktrees ohne Editor liefern „nicht gelaufen“; der Lauf bleibt + beim Orchestrator oder Inhaber. +- **Ein offener Editor blockiert den Lauf** (gewollt, mit klarer Meldung). +- **Ein Lauf kann den Arbeitsbaum verändern.** Das Skript meldet das, räumt aber + nicht auf; wer committet, prüft `git status`. +- **Beide Dokumente bekommen Version 5.1.0** und eine Verlaufszeile. +- **#110 schließt der Inhaber**, nicht dieser PR. +- **Offen und getrennt zu behandeln:** das SENTIS-Define in + `ProjectSettings.asset` (seit #143 auf `main` verloren) ist ein eigener Befund, + nicht Teil dieser Entscheidung. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/06-issue-110-unity-test-lane.md b/reports/v8.6.0/issue-lauf-2026-10-17/06-issue-110-unity-test-lane.md new file mode 100644 index 0000000..9a92236 --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/06-issue-110-unity-test-lane.md @@ -0,0 +1,145 @@ +--- +issue: 110 +paket: P-110 +branch: chore/110-unity-test-lanes-local +agent: builder (sonnet) +datum: 2026-10-17 +status: DONE +--- +# Unity-Spuren als lokale Pflichtspur (P-110, #110) + +## Aufgabe +Hälfte 1 von #110 (roter PlayMode-Test) ist seit `e3149f9` erledigt. Offen war Hälfte 2: Die +Unity-Tests laufen in keiner CI und hatten weder Kommando noch Auslöser noch Zuständigen. Umgesetzt +ist Option C/D aus der Triage: ein Skript, das beide Spuren ehrlich fährt, ein breiterer Auslöser in +den Vertragsdokumenten, eine Pflichtzeile in der PR-Vorlage und die Entscheidung D-125 (Entwurf). +Dazu der Regressionslauf in Unity auf `a6858d1`. + +## Was geändert wurde — Files Changed +- `tools/unity-tests/run_unity_tests.py` (neu, ausführbar) — `edit | play | all` und `--self-test`. + Version aus `ProjectVersion.txt`, Abbruch bei lebendem Editor (`Library/EditorInstance.json`, Prozess + wird geprüft; tote PID = Warnung, unlesbare Datei = Abbruch), nie `-quit`, `-nographics` nur + EditMode, alte Ergebnis-XML und altes Log werden vorher gelöscht, danach Existenz, Frische, Anzahl + `test-case` größer null, `failed == 0` und Unity-Exitcode 0. `git status` vor/nach mit Inhalts-Hash + verglichen; Warnung bei `ProjectSettings.asset`, `InitTestScene*`, `.unity-tests`. Ausgabe der + Beweiszeile „Unity-Spuren: EditMode N/N, PlayMode M/M“. Exit 0 grün, 1 rot/ungültiger Nachweis, 2 + Vorbedingung. Keine Abhängigkeit vom schlafenden Gate-Apparat. Standard-Ergebnisordner + `output/unity-tests/` (git-ignoriert). +- `AGENTS.md` — 5.0.0 auf 5.1.0, Verlaufszeile; DoD §3 um eine Unity-Zeile ergänzt, §4 ersetzt den + alten EditMode-Satz durch Auslöserliste, Zuständigkeit, Kommando und `-quit`-Falle. Keine Testanzahl. +- `CONTRIBUTING.md` — 5.0.0 auf 5.1.0, Verlaufszeile; §3 genauso (Absatz „Unity-Spuren“). +- `.github/pull_request_template.md` — Pflichtzeile „Unity-Spuren: EditMode N/N, PlayMode M/M — oder + nicht gelaufen mit Grund“ in der Checkliste. Die CLA-Prüfung sucht nur einen Teilstring und bleibt + unberührt. +- `docs/production/DecisionLog.md` — D-125 (Entwurf) am Ende des Entscheidungsteils vor „Offene + Punkte“. Kopfversion und Änderungsverlauf bewusst unangetastet. +- `CHANGELOG.md` — ein Eintrag unter `[Unreleased]` / „Hinzugefügt“ (Refs #110). +- Nicht angefasst: `.github/workflows/tests.yml`, die vier Guard-Dateien, `docs/README.md`, + `ScopeLedger.md`, Produktivcode, `MainMenuTests.cs`. + +## Tests und Prüfungen +Alle im Worktree `chore-110`, Unity `6000.5.4f1`, Basis `a6858d1` plus diese Änderungen +(die Änderungen betreffen nur Skript und Doku, kein Spielcode). + +- `python3 tools/unity-tests/run_unity_tests.py --self-test` — grün. Deckt: fehlende, leere, veraltete + (mtime 1 h alt), kaputte (kein valides XML), rote, nur-übersprungene und Null-Case-Ergebnisdatei + (jeweils nicht ok); grüne Datei ok; `-quit` nie im Kommando, `-nographics` nur EditMode; + Versions-Parsing; Editor-Sperre (lebende PID blockiert, tote PID nicht, Müll blockiert); + Arbeitsbaum-Diff und Rest-Warnungen. +- Zusätzlich von Hand gegen das echte Projekt mit einem Fake-Editor, der Exit 0 ohne Ergebnisdatei + liefert (die `-quit`-Falle): Exit 1, „result file missing“. Mit einer `EditorInstance.json` mit + lebender PID: Exit 2. Ohne Lane-Argument: Exit 2. +- **Unity EditMode** (`run_unity_tests.py edit`): **663/663 grün**, 0 fehlgeschlagen, 0 übersprungen. + Die Ergebnisdatei trägt `total=663 passed=663 failed=0`, 663 `test-case`-Knoten. +- **Unity PlayMode** (`run_unity_tests.py play`): **15/15 grün**. +- `run_unity_tests.py all` danach nochmals vollständig: **EditMode 663/663, PlayMode 15/15**, Beweiszeile + `Unity-Spuren: EditMode 663/663, PlayMode 15/15`. Die Warnungen am Ende dieses Laufs betrafen nur + `CHANGELOG.md` und `DecisionLog.md`, die ich währenddessen selbst geändert habe (Beleg, dass der + Vergleich greift). In den beiden Einzelläufen: „git status: the run left no new changes“. Es blieben + weder `InitTestScene*` noch Änderungen an `ProjectSettings.asset` zurück. +- `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` — **778/778 grün** + (14 s). +- `python3 .github/scripts/check_docs.py` — OK (203 Markdown-Dateien, 5 Quality-JSONs), nur der übliche + Hinweis auf 13 Dateien ohne Statuskopf. +- `python3 .github/scripts/check_baseline_guard.py --self-test` — grün. +- `git status` zum Schluss: 5 geänderte Dateien, `tools/unity-tests/` neu; sonst nichts. + +**Was nicht gelaufen ist / Einschränkungen:** +- Die Library wurde per `rsync` aus dem Hauptarbeitsbaum (Art-Branch-Stand) übernommen; Unity hat + Abweichungen neu importiert. Gemessen wurden die Quellen des Worktrees. Die Art-Assets (FBX/Prefabs, + git-ignoriert) liegen im Worktree nicht, die Szene fährt also mit Graybox-Primitiven. Das ist der + Stand eines frischen Klons, aber nicht der des Art-Branches. +- PlayMode lief mit Grafik im Batchmode ohne `-nographics`; Ergebnisse unter einem sichtbaren Editor + oder auf einem anderen Rechner sind nicht gemessen. +- Eine GameCI-/CI-Variante wurde nicht erprobt (bewusst vertagt, D-125). +- Das Skript wurde nur unter macOS gefahren; die Windows-/Linux-Pfade stehen im Code, sind aber nicht + getestet. Der `--self-test` läuft in keiner CI. +- Kein Issue-Kommentar, kein Folge-Issue, kein Schließen von #110 (nicht im Auftrag). + +## Entscheidung +D-125 (Entwurf), `docs/production/DecisionLog.md`: **C — lokal vor dem Merge, mit Skript, +Auslöserliste, Zuständigem und Pflichtzeile im PR**, Wiedervorlage von **A (GameCI, nur EditMode)** +bei (a) wieder erwarteten externen PRs, (b) geklärter Unity-Lizenz in CI, (c) einem weiteren rot +gemergten Unity-Test trotz Pflichtzeile. B (selbstgehosteter Runner) verworfen (öffentliches Repo mit +Fork-PRs). Die verlorene Worker-Empfehlung „PlayMode headless“ ist ausdrücklich verworfen: PlayMode +braucht Grafik. Belegkette #110, #141, #143, #144. Auslöser: `Scripts/{Simulation,Core,AI,AI.Data,Data, +Gameplay,Presentation}/`, `Editor/`, `Scenes/`, `Assets/Tests/`. Zuständig: wer den PR vorbereitet +(Orchestrator bei der Sprint-Integration), der Inhaber prüft die Zeile beim Merge. + +## Nebenbefunde +- **SENTIS-Define:** `SENTIS_ANALYTICS_ENABLED` fehlt in `ProjectSettings.asset` seit `85c912b` (#143). + In meinen Läufen hat Unity die Datei nicht erneut verändert. Eigener kleiner Befund, nicht Teil dieses + PR. +- **`Networking/` fehlt in der Auslöserliste** der Spezifikation (`Assets/_Project/Scripts/Networking/` + steht im Baseline-Guard als Verhaltensfläche). Ich habe die Liste wie vorgegeben übernommen. +- **Testkommentare** verweisen noch auf `quality/scripts/run_gate_check.py:462` für die `-quit`-Falle + (`GrayboxDemoProofTests.cs`, `MainMenuTests.cs`, `BarracksSpawnDiagnosisTests.cs`); das ist veraltet, + das neue Skript wäre der bessere Verweis. Nicht angefasst (Unity-Tests, fremder Scope). +- `docs/production/hashkrieg/22_Sprint_Findbarkeit_und_Vertraege.md:141, 205` nennt „PlayMode 13/13“; + gemessen sind 15. Abgeschlossenes Sprint-Dokument, bewusst unverändert. +- Härtung von `MainMenuTests` (Suche nach Name statt Text) ist optional und unnötig, solange der Test + grün läuft; nicht umgesetzt. +- Der `--self-test` ließe sich in `integrity` einhängen; das wäre eine Änderung unter + `.github/workflows/` (Sicherheitsfläche) und steht in D-125 als Kandidat für die Wiedervorlage. +- Der PlayMode-Lauf hat `output/demo/` im Worktree angelegt (git-ignoriert), ebenso liegt eine 5-GB- + `Library/` im Worktree; beides beim Aufräumen des Worktrees mit entfernen. + +## Offene Fragen an den Inhaber +1. Ist die Auslöserliste so richtig, oder soll `Assets/_Project/Scripts/**` (inklusive `Networking/`) + komplett gelten? +2. Zuständigkeit wie in D-125 (PR-Vorbereiter, beim Merge prüft der Inhaber) — einverstanden? +3. Pflichtzeile mit erlaubtem „nicht gelaufen“ — oder soll die Zeile für Auslöser-PRs strenger werden? +4. Soll das verlorene SENTIS-Define zurückgeholt werden (eigener PR)? +5. Wiedervorlage-Bedingungen für die EditMode-CI (a)–(c) so recht? Und ist Unity Personal in CI + lizenzrechtlich zulässig (kann der Code nicht beantworten)? +6. `Fixes #110` oder `Refs #110`? Ich habe `Refs` gewählt; „Fixes“ erst, wenn D-125 angenommen ist. +7. AGENTS.md ist das Steuerdokument aller Agenten: Die Änderung braucht die ausdrückliche Zustimmung + des Inhabers. + +## Vorschlag für Commit-Titel und PR-Beschreibung +**Commit-Titel:** `chore(tests): run Unity test lanes locally via one script (D-125)` + +**PR-Titel:** wie Commit-Titel. + +**PR-Beschreibung:** +- **Was:** `tools/unity-tests/run_unity_tests.py` fährt EditMode und PlayMode (nie `-quit`, + `-nographics` nur EditMode, Editor-Sperre, Frische-/Anzahl-/Fehlerprüfung, Arbeitsbaum-Vergleich, + `--self-test`). `AGENTS.md` und `CONTRIBUTING.md` auf 5.1.0 mit Auslöserliste, Zuständigkeit und + Kommando. PR-Vorlage mit Pflichtzeile „Unity-Spuren“. D-125 (Entwurf) im DecisionLog. +- **Warum:** Die Unity-Spuren laufen in keiner CI. #110, #141 und #143 zeigen, dass das Folgen hat; der + alte Auslöser (nur Präsentation/Gameplay) hätte #141 nicht erfasst. `tests.yml` bleibt unverändert, + die CI-Aufnahme nur für EditMode ist mit Wiedervorlage-Bedingungen vertagt. +- **Bereiche:** `tools/unity-tests/`, `AGENTS.md`, `CONTRIBUTING.md`, `.github/pull_request_template.md` + (CODEOWNERS Inhaber), `docs/production/DecisionLog.md`, `CHANGELOG.md`. Kein Spielcode, keine + Baselines. +- **Changelog:** „Unity-Spuren sind eine lokale Pflichtspur mit Skript, Auslöserliste und Nachweiszeile + im PR (D-125, Entwurf; Refs #110)“ unter Hinzugefügt. +- **Unity-Spuren:** gelaufen, EditMode 663/663, PlayMode 15/15, Unity 6000.5.4f1, Basis `a6858d1` + (Regressionsnachweis nach #144, das ohne Unity-Lauf gemergt wurde). Warme Library aus dem + Hauptarbeitsbaum, Graybox-Primitive statt Art-Assets. +- **Weitere Prüfungen:** `dotnet test` 778/778, `check_docs.py` OK, `check_baseline_guard.py + --self-test` grün, `run_unity_tests.py --self-test` grün. +- **Spielabnahme:** nicht gespielt. Grund: Der PR ändert kein Spielverhalten. Restrisiko: gering; das + Skript ist nur unter macOS gefahren, und die Pflichtzeile erzwingt den Lauf nicht, sie macht das + Auslassen nur sichtbar. +- Refs #110 (Schließen erst nach Annahme von D-125). diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/06b-review-1-issue-110.md b/reports/v8.6.0/issue-lauf-2026-10-17/06b-review-1-issue-110.md new file mode 100644 index 0000000..0d8f5f5 --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/06b-review-1-issue-110.md @@ -0,0 +1,159 @@ +--- +issue: 110 +paket: P-110 +branch: chore/110-unity-test-lanes-local +reviewer: opus +runde: 1 +verdict: APPROVED +datum: 2026-10-17 +--- +# Review 1 – P-110: Unity-Spuren als lokale Pflichtspur + +Unabhängiger Read-only-Review nach AGENTS.md §3. Ich habe keine Projektdatei geändert; einzige +geschriebene Datei ist dieser Bericht. Eigene Hilfsdateien liegen unter +`scratchpad/build-110/review1/` (außerhalb des Repos). + +## Was geprüft wurde + +- `git status --short` und der vollständige `git diff` im Worktree `chore-110` (Basis `a6858d1`), + dazu der Inhalt der neuen Datei `tools/unity-tests/run_unity_tests.py` (541 Zeilen) und der + Builder-Bericht `06-issue-110-unity-test-lane.md`. +- Abgleich gegen Issue #110, Paketspezifikation `paket-P-110.json` (Schritte, Schreibumfang, neue + Tests, Konflikte). +- Geänderte Dateien: `.github/pull_request_template.md`, `AGENTS.md`, `CHANGELOG.md`, + `CONTRIBUTING.md`, `docs/production/DecisionLog.md`; neu: `tools/unity-tests/`, Bericht. Alles im + Schreibumfang. `tests.yml`, `ProjectSettings/`, Produktivcode, `docs/README.md`, `ScopeLedger.md` + unberührt. Die vier Guard-Dateien sind unverändert (`git diff --quiet` auf alle vier: leer). +- Keine untrackten Testrunner-Reste: kein `InitTestScene*`, kein `.unity-tests/`. `output/` (darin + `output/demo/` aus dem PlayMode-Lauf) und `Library/` sind git-ignoriert. +- Belegkette in D-125 nachgeprüft: `a6858d1` (#144) ändert `MainMenuController.cs`, + `Nova.Gameplay.asmdef`, `Nova.AI.Tests.asmdef`; `e4faa75` (#141) nennt 754/754 headless; + `85c912b` (#143) nennt PlayMode 15/15, EditMode 663/663 und entfernt + `SENTIS_ANALYTICS_ENABLED` aus `ProjectSettings.asset`. `GrayboxLog.md` (Fallstrick `-quit`, PlayMode + ohne `-nographics`) und `GrayboxDemoProofTests.cs:30` bestätigen die Begründung gegen „PlayMode + headless“. + +## Nachgerechnete Ergebnisse (selbst gemessen) + +| Prüfung | Ergebnis | +|---|---| +| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` | 778/778 bestanden, 0 Fehler, 14 s | +| `python3 .github/scripts/check_docs.py` | OK, 204 Markdown-Dateien, 5 Quality-JSONs (übliche Statuskopf-Hinweise) | +| `python3 .github/scripts/check_baseline_guard.py --self-test` | bestanden, inkl. Negativkontrolle | +| `python3 tools/unity-tests/run_unity_tests.py --self-test` | Exit 0 | +| Skript ohne Lane-Argument | Exit 2 (argparse-Fehler) | +| Fake-Unity (`exit 0`, keine Ergebnisdatei = `-quit`-Falle), `edit` mit `--results-dir` im Scratchpad | Exit 1, „result file missing“, Beweiszeile „Unity-Spuren NICHT GRUEN: EditMode ROT“ | + +**Unity-Ergebnisse** (nicht selbst gestartet, sondern die XML unter `scratchpad/build-110/` mit eigenem +Parser nachgezählt, nur `test-case`-Knoten): + +| Datei | ``-Kopf | gezählte `test-case` | +|---|---|---| +| `results/editmode-results.xml` | total=663 passed=663 failed=0 skipped=0 | 663 × Passed | +| `results/playmode-results.xml` | total=15 passed=15 failed=0 | 15 × Passed | +| `results-all/editmode-results.xml` | total=663 passed=663 failed=0 | 663 × Passed | +| `results-all/playmode-results.xml` | total=15 passed=15 failed=0 | 15 × Passed | + +Die Zeitstempel (15:32–15:36Z) passen zu den Ausgaben `run-edit.out`, `run-play.out`, `run-all.out`; +die Befehlszeilen dort enthalten kein `-quit`, `-nographics` nur bei EditMode. Die Behauptung +„EditMode 663/663, PlayMode 15/15“ ist damit belegt. + +**Mutationsprobe des `--self-test`** (Kopien im Scratchpad): Entfernen von Frische-Prüfung, +Fehlschlag-Prüfung, Editor-Sperre, `-nographics`-Trennung oder Einfügen von `-quit` lässt den +Self-Test jeweils rot werden (Exit 1). Das Entfernen der Leer- und der Null-Case-Prüfung bleibt grün, +weil das Verhalten über die XML-Parse- bzw. „no test case passed“-Prüfung erhalten bleibt (kein +Defekt). Das Entfernen der Unity-Exitcode-Prüfung bleibt grün – siehe Befund N2. + +## Befunde + +### Hoch +Keine. + +### Mittel + +- **M1 – Auslöserliste deckt nachweislich testrelevante Flächen nicht ab.** + `AGENTS.md:89-91`, `CONTRIBUTING.md:61-64`, `.github/pull_request_template.md:11-12`, + `docs/production/DecisionLog.md` (D-125, „Auslöser“). Nicht enthalten sind: + `Assets/_Project/Scripts/Networking/` (eigene Test-Assembly + `Assets/Tests/EditMode/Networking/Nova.Networking.Tests.asmdef`; `MatchConfigTests` nutzt + `Nova.Networking`; der Baseline-Guard führt Networking als Verhaltensfläche), + `Assets/_Project/Data/` (die Registry- und Karten-Assets prüft `Nova.Data.Tests` – + `GameDatabaseTests`, `MapDefinitionTests`, `AssetIntegrationTests`), `Assets/_Project/UI/` + (PanelSettings/Themes, die die PlayMode-`MainMenuTests` – genau die Testklasse aus #110 – + rendern), `Packages/` und `ProjectSettings/`. Die Liste stammt aus der Spezifikation; der Builder + hat sie korrekt übernommen und die Lücke selbst als offene Frage gemeldet. Kein Scope-Verstoß, + aber die Regel soll ja gerade #141-artige Überraschungen fangen. + *Vorschlag:* Auslöser vereinfachen auf „alles unter `Assets/`, `Packages/`, `ProjectSettings/`“ + – kürzer, nicht lückenhaft, und in allen vier Stellen gleich. Inhaber-Entscheidung. + +- **M2 – Editor-Sperre ist unter Windows wirkungslos.** `tools/unity-tests/run_unity_tests.py:183-194`. + `os.kill(pid, 0)` ist unter Windows keine Existenzprüfung: Signal 0 ist dort + `CTRL_C_EVENT` und geht über `GenerateConsoleCtrlEvent` an eine Prozessgruppe. Für einen + GUI-Editor scheitert das typischerweise mit `OSError` → `_pid_alive` liefert `False` → die Sperre + greift nicht (fail-open), und der Self-Test-Fall „live editor pid blocks the run“ dürfte dort rot + sein. Der Builder hat Windows/Linux ehrlich als ungetestet ausgewiesen, der Code (Hub-Pfad + `C:/Program Files/...`) suggeriert aber Unterstützung. Heute betrifft das niemanden (Inhaber auf + macOS). + *Vorschlag:* unter `os.name == "nt"` bei vorhandener `EditorInstance.json` fail-closed abbrechen + oder die Liveness per `ctypes`/`OpenProcess` prüfen; alternativ im Docstring „nur macOS/Linux + geprüft, Windows: Sperre fail-closed“ festhalten. + +### Niedrig + +- **N1 – Gefilterter Lauf erzeugt dieselbe Beweiszeile wie ein Volllauf.** + `run_unity_tests.py:377-383`. Mit `--test-filter Fixture` lautet die letzte Zeile z. B. + „Unity-Spuren: EditMode 3/3“ – kopierbar in die PR-Pflichtzeile, ohne dass erkennbar ist, dass + nur ein Ausschnitt lief. *Vorschlag:* bei gesetztem Filter „(gefiltert: …)“ anhängen oder das + Präfix „Unity-Spuren:“ unterdrücken. +- **N2 – Der Self-Test deckt `run_lane`/`main_run` nicht ab.** Eine Mutation, die die + Unity-Exitcode-Prüfung (`run_unity_tests.py:321-325`) entfernt, lässt `--self-test` grün. Der + Builder hat den Pfad von Hand mit einem Fake-Editor geprüft, und ich habe den Fall „Exit 0 ohne + Ergebnisdatei“ reproduziert (Exit 1). *Vorschlag:* im Self-Test `run_lane` einmal mit einem + Fake-Binary fahren, das eine grüne XML schreibt und mit 2 endet (erwartet: nicht ok). +- **N3 – Docstring überzeichnet den Arbeitsbaum-Vergleich.** `run_unity_tests.py:22-23` („every + change the run itself caused“): Der Vergleich sieht jede Änderung während des Laufs, auch fremde. + Der `all`-Lauf des Builders meldete `CHANGELOG.md` und `DecisionLog.md`, die er parallel selbst + bearbeitet hat – im Bericht offen erklärt. *Vorschlag:* „every change made while the run was in + progress“. +- **N4 – `tests.yml:14-15` widerspricht jetzt der neuen Regel** („Wer die Präsentationsschicht + anfasst …“). Bewusst unangetastet (Sicherheitsfläche, Spezifikation); beim nächsten Workflow-PR + mitnehmen. +- **N5 – Zahl im Bericht leicht abweichend:** Bericht nennt bei `check_docs.py` 203 Markdown-Dateien, + die Builder-Rückmeldung und meine Messung 204. Erklärbar dadurch, dass der Bericht selbst erst + danach entstand; kein Ehrlichkeitsproblem. + +## Doku, Changelog, D-ID + +- `AGENTS.md` 5.0.0 → 5.1.0 mit Verlaufszeile, Änderung auf DoD-Zeile (§3) und Testabschnitt (§4) + beschränkt, keine festen Testanzahlen – wie gefordert. Die Änderung am Steuerdokument braucht die + ausdrückliche Zustimmung des Inhabers (vom Builder als offene Frage gestellt). +- `CONTRIBUTING.md` 5.0.0 → 5.1.0 mit Verlaufszeile, §3 konsistent zu AGENTS.md. +- PR-Vorlage: Pflichtzeile vorhanden; CLA-Marker (`check_external_contributor_review.py:14`) unberührt. +- `CHANGELOG.md`: genau ein Eintrag unter `[Unreleased]` → „Hinzugefügt“, mit „Refs #110“ und D-125. + Kategorie passt (neues Werkzeug); „Entschieden“ wäre ebenfalls vertretbar. +- D-125: richtige Position (nach D-114, vor „## Offene Punkte“), Kopfzeile im Nachbarformat, + Status „Entwurf … verbindlich mit dem Merge des PR“, vier bewertete Alternativen (A GameCI, + B selbstgehosteter Runner, C lokal mit Pflicht, Status quo), Entscheidung, Begründung, + Konsequenzen inkl. Wiedervorlage-Bedingungen. Kopfversion/Verlauf des DecisionLog unberührt. +- Tote Links: keine (`check_docs.py` OK). +- Determinismus: nicht berührt (kein Code unter `Assets/_Project/Scripts/`). + +## Ehrlichkeit des Builder-Berichts + +Alle nachprüfbaren Zahlen stimmen mit meiner Messung bzw. den Ergebnis-XML überein (778/778, +663/663, 15/15, Self-Test grün, Fake-Editor Exit 1). Nicht-Gelaufenes ist ausgewiesen: keine CI, +nur macOS, keine Art-Assets im Worktree (Graybox-Primitive), Library per rsync aus dem +Hauptarbeitsbaum, nicht gespielt. Bericht ehrlich. + +## Empfehlung für den Inhaber + +**APPROVED** – mergebar nach grüner Pflicht-CI, sofern du der AGENTS.md-Änderung ausdrücklich +zustimmst. Vor dem Merge entscheiden: + +1. M1: Auslöserliste auf „`Assets/`, `Packages/`, `ProjectSettings/`“ verbreitern (empfohlen) oder + wie vorliegend mit Networking/Data/UI-Lücke annehmen. +2. M2: Windows-Sperre jetzt fail-closed machen oder als bekannte Grenze im Docstring/D-125 notieren. +3. N1/N2 sind kleine Härtungen am eigenen Skript und können in derselben Runde mit. + +Commit-Titel des Builders (`chore(tests): run Unity test lanes locally via one script (D-125)`, +67 Zeichen) passt zur Konvention. diff --git a/tools/unity-tests/run_unity_tests.py b/tools/unity-tests/run_unity_tests.py new file mode 100755 index 0000000..fd06175 --- /dev/null +++ b/tools/unity-tests/run_unity_tests.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +"""Run the Unity EditMode and PlayMode test lanes locally and prove they ran. + +The CI only runs ``dotnet test tools/Nova.SimRunner.Tests`` (no Unity licence +there). The two Unity lanes under ``Assets/Tests/`` therefore run nowhere unless +somebody starts them by hand, and a lane that runs nowhere warns nobody (#110, +D-125). This script is the one command for that hand run. It exists to make a +green result *believable*, not merely to start the editor: + +* ``-quit`` is never passed. Together with ``-runTests`` it makes Unity exit + before any test ran, write no result file and still report success. +* The previous result file is deleted first, so an old green file can never be + mistaken for this run. +* After the run it checks that the result file exists, is fresh, contains at + least one test case and has no failed one. A missing, empty, stale, unreadable + or red file is a failure (exit 1). +* ``-nographics`` is passed for EditMode only. PlayMode renders (screenshot + capture, UI Toolkit panels) and needs the GPU. +* It refuses to start while an editor has the project open + (``Library/EditorInstance.json``), because the batch run would otherwise fail + with a misleading "project is locked" message. +* ``git status`` is compared before and after, and every change the run itself + caused is reported. A test run is known to rewrite + ``ProjectSettings/ProjectSettings.asset`` and to leave ``InitTestScene*`` + scenes behind; this script warns about both and never reverts anything. + +Usage (repository root):: + + python3 tools/unity-tests/run_unity_tests.py all # EditMode, then PlayMode + python3 tools/unity-tests/run_unity_tests.py edit + python3 tools/unity-tests/run_unity_tests.py play + python3 tools/unity-tests/run_unity_tests.py --self-test + +The Unity version comes from ``ProjectSettings/ProjectVersion.txt``. The editor +binary is looked up in the Unity Hub default location of the platform, or taken +from ``--unity`` / the ``UNITY_PATH`` environment variable. Results and logs go +to ``output/unity-tests/`` (git-ignored) unless ``--results-dir`` says otherwise. + +On success the last line is the evidence line for the pull request template, +for example ``Unity-Spuren: EditMode 10/10, PlayMode 3/3``. Exit codes: +0 = every requested lane is green, 1 = a lane is red or its evidence is +invalid, 2 = a precondition failed (editor open, Unity missing, bad usage). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +REPO_ROOT = Path(__file__).resolve().parents[2] + +LANES = { + "edit": ("EditMode", True), # (Unity -testPlatform value, pass -nographics) + "play": ("PlayMode", False), # PlayMode renders and needs a graphics device +} + +# Files a Unity test run is known to touch. They get a dedicated warning +# because they are the ones that slip into a commit unnoticed. +KNOWN_RESIDUE = ( + (re.compile(r"(^|/)InitTestScene[^/]*\.unity(\.meta)?$"), + "temporary test-runner scene left behind; delete it before committing"), + (re.compile(r"^ProjectSettings/ProjectSettings\.asset$"), + "the run rewrote ProjectSettings.asset (scripting define symbols are the " + "usual victim); inspect `git diff` and revert it unless intended"), + (re.compile(r"(^|/)\.unity-tests(/|$)"), + "test-runner scratch directory left behind; delete it before committing"), +) + +# Freshness tolerance for file-system timestamp granularity. +FRESHNESS_SLACK_SECONDS = 2.0 + + +# -------------------------------------------------------------------------- +# Result evaluation +# -------------------------------------------------------------------------- + +class LaneResult: + """Outcome of evaluating one NUnit3 result file.""" + + def __init__(self, ok: bool, passed: int, failed: int, skipped: int, + problems: list[str]) -> None: + self.ok = ok + self.passed = passed + self.failed = failed + self.skipped = skipped + self.problems = problems + + @property + def total(self) -> int: + return self.passed + self.failed + self.skipped + + def fraction(self) -> str: + """``N/N`` when everything passed, ``P/T`` otherwise.""" + return f"{self.passed}/{self.total}" + + +def evaluate_results(path: Path, not_before: float) -> LaneResult: + """Judge a Unity result file. ``not_before`` is the run's start time. + + The file is accepted only if it exists, is not older than the run start, + parses as NUnit3 XML, contains at least one ``test-case`` and no failed one. + Counting happens on ``test-case`` nodes only: suites and fixtures carry a + ``result`` attribute as well and would inflate every number. + """ + problems: list[str] = [] + try: + stat = path.stat() + except OSError: + return LaneResult(False, 0, 0, 0, [f"result file missing: {path}"]) + if stat.st_size == 0: + return LaneResult(False, 0, 0, 0, [f"result file is empty: {path}"]) + if stat.st_mtime < not_before - FRESHNESS_SLACK_SECONDS: + age = not_before - stat.st_mtime + return LaneResult( + False, 0, 0, 0, + [f"result file is stale ({age:.0f} s older than this run): {path}"]) + try: + root = ET.parse(path).getroot() + except (ET.ParseError, OSError) as error: + return LaneResult(False, 0, 0, 0, [f"result file is not valid XML: {error}"]) + + passed = failed = skipped = 0 + failed_names: list[str] = [] + for case in root.iter("test-case"): + outcome = case.get("result", "") + if outcome == "Passed": + passed += 1 + elif outcome == "Failed": + failed += 1 + failed_names.append(case.get("fullname") or case.get("name") or "?") + else: # Skipped, Inconclusive, Ignored: visible, but not a failure + skipped += 1 + + if passed + failed + skipped == 0: + problems.append("result file contains no test case (nothing ran)") + if failed: + shown = ", ".join(failed_names[:5]) + more = f" (+{len(failed_names) - 5} more)" if len(failed_names) > 5 else "" + problems.append(f"{failed} test case(s) failed: {shown}{more}") + if passed == 0 and not problems: + problems.append("no test case passed") + return LaneResult(not problems, passed, failed, skipped, problems) + + +# -------------------------------------------------------------------------- +# Preconditions +# -------------------------------------------------------------------------- + +def read_unity_version(repo: Path) -> str: + text = (repo / "ProjectSettings" / "ProjectVersion.txt").read_text(encoding="utf-8") + match = re.search(r"^m_EditorVersion:\s*(\S+)\s*$", text, re.MULTILINE) + if not match: + raise ValueError("m_EditorVersion not found in ProjectSettings/ProjectVersion.txt") + return match.group(1) + + +def default_unity_candidates(version: str) -> list[Path]: + home = Path.home() + return [ + Path(f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"), + Path(f"C:/Program Files/Unity/Hub/Editor/{version}/Editor/Unity.exe"), + home / f"Unity/Hub/Editor/{version}/Editor/Unity", + ] + + +def find_unity(version: str, explicit: str | None) -> Path | None: + chosen = explicit or os.environ.get("UNITY_PATH") + if chosen: + path = Path(chosen) + return path if path.is_file() else None + return next((c for c in default_unity_candidates(version) if c.is_file()), None) + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def editor_lock_problem(repo: Path) -> str | None: + """Return a message when an editor appears to have the project open. + + ``Library/EditorInstance.json`` is written by a running editor and records + its ``process_id``. A file whose process is gone is the leftover of a crash; + it is reported by the caller as a warning, not as a block. A file that + cannot be read counts as a live editor (fail closed). + """ + marker = repo / "Library" / "EditorInstance.json" + if not marker.exists(): + return None + pid = None + try: + pid = int(json.loads(marker.read_text(encoding="utf-8")).get("process_id", 0)) + except (OSError, ValueError, TypeError, AttributeError): + return (f"{marker} exists but is unreadable; assuming the editor has the " + "project open. Close it (or delete the file if no editor runs).") + if _pid_alive(pid): + return (f"the Unity editor (pid {pid}) has this project open. Close it " + "first; a batch run would fail with a misleading lock message.") + return None + + +def build_command(unity: Path, repo: Path, lane: str, results: Path, log: Path, + test_filter: str | None) -> list[str]: + platform_name, no_graphics = LANES[lane] + command = [str(unity), "-batchmode"] + if no_graphics: + command.append("-nographics") + command += [ + "-projectPath", str(repo), + "-runTests", + "-testPlatform", platform_name, + "-testResults", str(results), + "-logFile", str(log), + ] + if test_filter: + command += ["-testFilter", test_filter] + # Never "-quit": it makes -runTests exit before any test ran (see docstring). + return command + + +# -------------------------------------------------------------------------- +# git status comparison +# -------------------------------------------------------------------------- + +def _file_digest(repo: Path, relative: str) -> str: + path = repo / relative + try: + if path.is_file(): + return hashlib.sha1(path.read_bytes()).hexdigest() + return "dir" if path.exists() else "gone" + except OSError: + return "unreadable" + + +def git_snapshot(repo: Path) -> dict[str, str] | None: + """Map every dirty path to ``status:content-digest``; ``None`` if no git.""" + try: + out = subprocess.run( + ["git", "-C", str(repo), "status", "--porcelain=v1", "-z", + "--untracked-files=all"], + capture_output=True, check=True, timeout=120, + ).stdout.decode("utf-8", "replace") + except (OSError, subprocess.SubprocessError): + return None + snapshot: dict[str, str] = {} + entries = out.split("\0") + index = 0 + while index < len(entries): + entry = entries[index] + index += 1 + if len(entry) < 4: + continue + status, name = entry[:2], entry[3:] + if status[0] in "RC": # rename/copy: the next token is the origin path + index += 1 + snapshot[name] = f"{status}:{_file_digest(repo, name)}" + return snapshot + + +def diff_snapshots(before: dict[str, str], after: dict[str, str]) -> list[str]: + """Paths the run itself created or changed (new or different digest).""" + return sorted(p for p, state in after.items() if before.get(p) != state) + + +def residue_warning(path: str) -> str | None: + for pattern, advice in KNOWN_RESIDUE: + if pattern.search(path): + return advice + return None + + +# -------------------------------------------------------------------------- +# Running a lane +# -------------------------------------------------------------------------- + +def run_lane(lane: str, unity: Path, repo: Path, results_dir: Path, + timeout: int, test_filter: str | None) -> LaneResult: + platform_name, _ = LANES[lane] + results = results_dir / f"{platform_name.lower()}-results.xml" + log = results_dir / f"{platform_name.lower()}-tests.log" + results_dir.mkdir(parents=True, exist_ok=True) + for stale in (results, log): + if stale.exists(): + stale.unlink() + + command = build_command(unity, repo, lane, results, log, test_filter) + assert "-quit" not in command, "-quit would skip the whole test run" + print(f"==> {platform_name}: {' '.join(command)}", flush=True) + started = time.time() + try: + completed = subprocess.run(command, timeout=timeout, check=False) + exit_code: int | None = completed.returncode + except subprocess.TimeoutExpired: + exit_code = None + except OSError as error: + return LaneResult(False, 0, 0, 0, [f"could not start Unity: {error}"]) + elapsed = time.time() - started + + result = evaluate_results(results, started) + if exit_code is None: + result.ok = False + result.problems.append(f"timed out after {timeout} s (log: {log})") + elif exit_code != 0: + # Unity exits 2 when tests fail and 3 when the run itself broke + # (compile errors, unreadable project). Either way this lane is not green. + result.ok = False + result.problems.append(f"Unity exited with code {exit_code} (log: {log})") + print(f" {platform_name}: {result.fraction()} passed" + + (f", {result.failed} failed" if result.failed else "") + + (f", {result.skipped} skipped/inconclusive" if result.skipped else "") + + f" in {elapsed:.0f} s", flush=True) + for problem in result.problems: + print(f" PROBLEM: {problem}", flush=True) + return result + + +def main_run(args: argparse.Namespace) -> int: + repo = Path(args.project).resolve() + try: + version = read_unity_version(repo) + except (OSError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 2 + unity = find_unity(version, args.unity) + if unity is None: + print(f"ERROR: Unity {version} not found. Install it via Unity Hub or point " + "--unity / UNITY_PATH at the editor binary.", file=sys.stderr) + return 2 + lock = editor_lock_problem(repo) + if lock: + print(f"ERROR: {lock}", file=sys.stderr) + return 2 + marker = repo / "Library" / "EditorInstance.json" + if marker.exists(): + print(f"WARNING: stale {marker} (its process is gone); continuing.") + + lanes = ["edit", "play"] if args.lane == "all" else [args.lane] + results_dir = Path(args.results_dir) if args.results_dir else repo / "output" / "unity-tests" + results_dir = results_dir.resolve() + print(f"Unity {version} at {unity}\nResults: {results_dir}", flush=True) + + before = git_snapshot(repo) + outcomes: dict[str, LaneResult] = {} + for lane in lanes: + outcomes[lane] = run_lane(lane, unity, repo, results_dir, args.timeout, args.test_filter) + + # Residue check: report what the run changed in the working tree. + after = git_snapshot(repo) + if before is None or after is None: + print("WARNING: git status unavailable; working-tree changes were not compared.") + else: + changed = diff_snapshots(before, after) + for path in changed: + advice = residue_warning(path) + print(f"WARNING: the run changed {path}" + (f" - {advice}" if advice else "")) + if not changed: + print("git status: the run left no new changes in the working tree.") + + parts = [] + for lane in lanes: + label = LANES[lane][0] + outcome = outcomes[lane] + parts.append(f"{label} {outcome.fraction()}" if outcome.ok else f"{label} ROT") + green = all(outcome.ok for outcome in outcomes.values()) + print(("Unity-Spuren: " if green else "Unity-Spuren NICHT GRUEN: ") + ", ".join(parts)) + return 0 if green else 1 + + +# -------------------------------------------------------------------------- +# Self-test +# -------------------------------------------------------------------------- + +def _fake_xml(cases: list[tuple[str, str]]) -> str: + rows = "".join( + f'' + for i, (name, result) in enumerate(cases) + ) + return ('' + '{rows}' + '') + + +def self_test() -> int: + failures: list[str] = [] + + def expect(label: str, condition: bool) -> None: + if not condition: + failures.append(label) + + with tempfile.TemporaryDirectory(prefix="nova-unity-selftest-") as scratch: + root = Path(scratch) + now = time.time() + + # Evaluation: every defect yields not-ok, only the green file passes. + missing = evaluate_results(root / "missing.xml", now) + expect("missing file must fail", not missing.ok) + + empty_file = root / "empty.xml" + empty_file.write_text("", encoding="utf-8") + expect("zero-byte file must fail", not evaluate_results(empty_file, now - 5).ok) + + no_cases = root / "nocases.xml" + no_cases.write_text(_fake_xml([]), encoding="utf-8") + result = evaluate_results(no_cases, now - 5) + expect("file without test cases must fail", not result.ok) + expect("file without test cases must say so", + any("no test case" in p for p in result.problems)) + + garbage = root / "garbage.xml" + garbage.write_text(" int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("lane", nargs="?", choices=["edit", "play", "all"], + help="which lane to run") + parser.add_argument("--self-test", action="store_true", + help="check the result evaluation against faked result files") + parser.add_argument("--project", default=str(REPO_ROOT), + help="Unity project path (default: this repository)") + parser.add_argument("--unity", help="path to the Unity binary (default: Hub location " + "for the version in ProjectVersion.txt, or UNITY_PATH)") + parser.add_argument("--results-dir", + help="where result XML and logs go (default: output/unity-tests)") + parser.add_argument("--timeout", type=int, default=1800, + help="seconds per lane before the run is killed (default 1800)") + parser.add_argument("--test-filter", help="forwarded as -testFilter (e.g. a fixture name)") + args = parser.parse_args() + + if args.self_test: + return self_test() + if not args.lane: + parser.error("a lane (edit, play, all) is required unless --self-test is used") + return main_run(args) + + +if __name__ == "__main__": + sys.exit(main())