diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9b303b..c35f97b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,33 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.ref_name }} AndiGeneratorNET-${{ steps.version.outputs.version }}-sbom.spdx.json + # Der Abschnitt „## “ aus CHANGELOG.md wird zum Text des Releases. Fehlt er, bleibt der Text leer. + - name: Release Notes aus CHANGELOG.md + continue-on-error: true + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = '${{ steps.version.outputs.version }}' + $zeilen = @(Get-Content CHANGELOG.md -Encoding utf8) + $start = -1 + for ($i = 0; $i -lt $zeilen.Count; $i++) { + if ($zeilen[$i] -match ('^## ' + [regex]::Escape($version) + '(\s|$)')) { $start = $i; break } + } + if ($start -lt 0) { + Write-Host "Kein Abschnitt '## $version' in CHANGELOG.md - der Release-Text bleibt leer." + exit 0 + } + $ende = $zeilen.Count + for ($i = $start + 1; $i -lt $zeilen.Count; $i++) { + if ($zeilen[$i] -match '^## ') { $ende = $i; break } + } + $text = '' + if ($ende -gt $start + 1) { $text = ($zeilen[($start + 1)..($ende - 1)] -join "`n").Trim() } + $text += "`n`nAlle Änderungen: [CHANGELOG.md](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md)" + Set-Content -Path release-notes.md -Value $text -Encoding utf8 + gh release edit ${{ github.ref_name }} --notes-file release-notes.md + # Linux (Chromebooks mit Linux-Entwicklungsumgebung, auch andere Linux-Systeme): eigenständig veröffentlichen und als # Debian-Paket und tar.gz an das Release hängen, das der Windows-Job angelegt hat. Getestet wird im Windows-Job. linux: diff --git a/.gitignore b/.gitignore index dca0b57..93cc6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ optimierung-log.txt abdeckung-log.txt messen-log.txt inselvergleich-*.txt +korrekturvergleich/ bildschirmfotos-log.txt # Private Originaldaten (Klarnamen) – öffentlich nur die anonymisierte Kopie unter testdaten/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e661f24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Änderungen + +Was sich von Version zu Version für Anwender ändert. Die Einzelheiten stehen in der [Anleitung](Doku/ANLEITUNG.md), die technische Geschichte im [Migrationsplan](Doku/MIGRATIONSPLAN.md). + +## 0.9.6 + +### Neu + +- **Absturzschutz:** Ein unerwarteter Fehler beendet das Programm nicht mehr still. Der aktuelle Plan wird als gemerkter Plan „Notfallsicherung“ abgelegt, die Einzelheiten kommen in `fehlerprotokoll.txt` im Datenordner, und eine Meldung sagt, wo sie liegen. Das gilt auch, wenn eine laufende Generierung abbricht. +- Die Oberfläche ist auch auf einem anders eingestellten System deutsch (Monatsnamen, Zahlen, Datumsformate); leere Zeitfelder zeigen „Stunde“ und „Minute“. +- **Leitstand** (vorher Maschinenraum): Rechenkerne, Schaubild Inseln → Koordinator → bester Plan mit einem grünen Punkt je Verbesserung und einem Kasten, was sich geändert hat; im Automodus die angehobenen Gewichte. +- **Verlauf:** Kosten bzw. Verstöße A, B und C seit dem Start, mit den Phasen des Automodus. +- **Gesicherter Plan:** Bevor ein Plan durch einen Neustart oder das Schließen verloren ginge, wird er gesichert bzw. mit dem bisher gesicherten verglichen und nachgefragt. +- **Spielplandaten:** Kalender der spielfreien Tage mit Grund im Tooltip, Wunschtermine als Kärtchen mit Ziehen und Ablegen (Strg kopiert) und Vorschlag der üblichen Uhrzeit, Setzliste per Ziehen, einheitliche Knöpfe neben den Listen, Marken und Änderungspunkte in der Seitenliste. + +### Geändert + +- **Fehler des Originals korrigiert:** Ein Wunschtermin gilt beim Zurücksetzen eines Spiels nicht mehr fälschlich als frei; beim Füllen über die Auswärtskoppel wird kein schon terminiertes Spiel mehr umgesetzt; das Startraster legt bei ungerader Mannschaftszahl alle Spieltage an; beim letzten Spieltag zählen alle Überlappungen (die Kosten können dadurch etwas höher sein als im alten AndiGenerator). +- **Pflichtspieltag bei ungerader Mannschaftszahl:** Die zwangsläufig spielfreie Mannschaft wird nicht mehr bestraft, wenn sie in der Woche davor spielt (abschaltbar in den Einstellungen). +- Kopfzeile: ▶ Start / ⏸ Pausieren und ✕ Schließen / ■ Stopp; das Verfahren wird mit zwei Segmentknöpfen gewählt. +- Die Frage „nur Rückrunde planen?“ nennt den Beginn der Rückrunde. + +## 0.9.5 (10.10.2026) + +### Neu + +- Version für Macs (Apple-Chip und Intel) und für Windows auf ARM (Snapdragon). + +## 0.9.4 (05.10.2026) + +### Neu + +- **Automodus:** Die Suche lenkt sich selbst über die Gewichte – erst Pflichtregeln (Stufe A), dann Wünsche (B), dann die übrige Güte (C). +- Pakete für Chromebooks und Linux; die Bildschirmfotos der Anleitung entstehen automatisch. + +## 0.9.3 (01.10.2026) + +Erste öffentliche Beta: vollständige Portierung des AndiGenerators von Andreas Hofmann nach C#/.NET 10 mit neuer Oberfläche – Optimierung und Bewertung wie im Original, alle Ansichten, Spielplandaten, Export für click-TT, Druck und Excel, Installation und Updates über GitHub Releases. diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md index 02e8a02..d40362e 100644 --- a/CODE_SIGNING.md +++ b/CODE_SIGNING.md @@ -7,7 +7,7 @@ Die Programmdateien und das Setup von AndiGenerator.NET sollen künftig digital signiert werden, damit Windows sie als vertrauenswürdig erkennt. **Kostenlose Code-Signierung bereitgestellt von [SignPath.io](https://about.signpath.io), Zertifikat von der [SignPath Foundation](https://signpath.org).** -*(Die Aufnahme in das Programm der SignPath Foundation ist beantragt. Bis zur Zusage sind die veröffentlichten Dateien noch nicht signiert.)* +*(Die SignPath Foundation hat den ersten Antrag im Oktober 2026 abgelehnt: Das Projekt sei öffentlich noch nicht bekannt genug. Eine erneute Bewerbung ist ausdrücklich möglich, sobald es breiter genutzt wird. Bis dahin sind die veröffentlichten Dateien nicht signiert; eine bezahlte Signierung ist derzeit nicht vorgesehen. Die Herkunftsnachweise von GitHub (siehe unten) belegen trotzdem, dass jede Datei aus diesem Repository gebaut wurde.)* Signiert werden ausschließlich Dateien, die aus dem Quellcode dieses Repositorys gebaut werden: @@ -44,4 +44,4 @@ Links, die Sie im Programm anklicken (z. B. zum Lizenztext), öffnen Ihren Brows ### English summary -Free code signing provided by SignPath.io, certificate by SignPath Foundation (application pending; releases are unsigned until approval). Only binaries built from this repository by GitHub Actions are signed, and every signing request is approved manually. Committer, reviewer and approver: Peter Buchmann (@PBuchmann), using multi-factor authentication. Privacy: the program does not transfer any data to other networked systems, except for an update check against GitHub at startup, which sends no user data and can be turned off in the *About* dialog. +Free code signing provided by SignPath.io, certificate by SignPath Foundation (first application declined in October 2026 for lack of public visibility; we will reapply once the project is more widely used – until then releases are unsigned, but carry GitHub build provenance attestations). Only binaries built from this repository by GitHub Actions are signed, and every signing request is approved manually. Committer, reviewer and approver: Peter Buchmann (@PBuchmann), using multi-factor authentication. Privacy: the program does not transfer any data to other networked systems, except for an update check against GitHub at startup, which sends no user data and can be turned off in the *About* dialog. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59f8a19..9492137 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,16 @@ Auf `main` wird nicht direkt gepusht. Jede Änderung läuft über einen eigenen Kleine, in sich abgeschlossene Pull Requests lassen sich schneller prüfen als große. +## Neue Version + +Was sich für Anwender ändert, kommt mit jedem Pull Request in [CHANGELOG.md](CHANGELOG.md) unter **Unveröffentlicht**. Vor einer neuen Version wird diese Überschrift zu `## x.y.z (Datum)`; danach den Tag `vx.y.z` auf `main` setzen. Der Release-Workflow übernimmt den Abschnitt der Version als Text des Releases auf GitHub. + ## Code-Stil - **Warnungen sind Fehler** – auch die von StyleCop und SonarAnalyzer. Einstellungen in `.editorconfig` und `stylecop.json`. - **Deutsche Fachbegriffe** für Klassen, Methoden und Variablen (`Mannschaft`, `Spieltag`, `Wunschtermin`), technische Begriffe englisch, wo sie üblich sind (`Command`, `ViewModel`). Kommentare und XML-Dokumentation auf Deutsch. - **Schichten einhalten:** Domain ← Engine ← Persistence ← Application ← Presentation ← UI ← Desktop; Rendering zeichnet für Bildschirm und PDF. Die Presentation-Schicht kennt Avalonia nicht. Die Architekturtests prüfen das. -- **Referenzmodell:** `src/AndiGenerator.Engine/Referenz` ist eine bewusst wörtliche Portierung des Originals und dient als Maßstab der Paritätstests. Änderungen dort nur, wenn das Verhalten des Originals falsch übertragen wurde. +- **Referenzmodell:** `src/AndiGenerator.Engine/Referenz` ist eine bewusst wörtliche Portierung des Originals und dient als Maßstab der Paritätstests. Änderungen dort nur, wenn das Verhalten des Originals falsch übertragen wurde, oder als abschaltbare Korrektur eines Fehlers des Originals (`Domain.Optionen.Korrekturen`; `Berechnungsoptionen.Original` rechnet weiter wie das Original). - **Fachliche Abweichungen vom Original** nur nach Absprache und als Eintrag im Migrationsplan (Abschnitt Befunde/Entscheidungen). ## Dateikopf und Lizenz diff --git a/Doku/ANLEITUNG.md b/Doku/ANLEITUNG.md index 5ce5fe4..dd913dc 100644 --- a/Doku/ANLEITUNG.md +++ b/Doku/ANLEITUNG.md @@ -392,7 +392,7 @@ Gewichten lässt sich auf drei Ebenen, die sich addieren: ![Gewichtung im Detailfeld](Anleitung/Bilder/09-gewichtung.png) -**In den Einstellungen** (links unter *Staffel*) finden Sie alle Gewichtungen auf einer Seite: die allgemeinen Plankosten, die Mannschaftskosten und die Gewichtung je Mannschaft. Auch hier gilt jede Änderung sofort. **Standard wiederherstellen …** setzt alles auf die Ausgangswerte zurück. +**In den Einstellungen** (links unter *Staffel*) finden Sie alle Gewichtungen auf einer Seite: die allgemeinen Plankosten, die Mannschaftskosten und die Gewichtung je Mannschaft. Unter den Mannschaftskosten stehen außerdem die Freitagsregel für die 60-km-Regel und die Regel für den Pflichtspieltag bei ungerader Mannschaftszahl (siehe [Pflichtspieltage](#pflichtspieltage)). Auch hier gilt jede Änderung sofort. **Standard wiederherstellen …** setzt alles auf die Ausgangswerte zurück. ![Einstellungen](Anleitung/Bilder/10-einstellungen.png) @@ -535,7 +535,7 @@ An einzelnen Tagen soll gar nicht gespielt werden – etwa weil die Relegationst Hier legen Sie fest, wie viele Spiele jede Mannschaft in einem Zeitraum mindestens machen muss – etwa mindestens ein Spiel zwischen dem 14. und 18. April oder mindestens drei Spiele im November. -Setzen Sie Pflichtspieltage sparsam ein: Sie nehmen dem Programm Freiheit und verschlechtern den Plan meist. Sinnvoll sind sie vor allem für einen gemeinsamen letzten Spieltag, falls das Programm ihn nicht ohnehin so plant. Bei **ungerader Mannschaftszahl** muss am Pflichtspieltag zwangsläufig eine Mannschaft pausieren und bekäme sonst in einer Woche zwei Spiele. Verlängern Sie dann den Zeitraum, oder löschen Sie die Pflichtspieltage und lassen Sie das Programm den letzten Spieltag über die Kosten optimieren. +Setzen Sie Pflichtspieltage sparsam ein: Sie nehmen dem Programm Freiheit und verschlechtern den Plan meist. Sinnvoll sind sie vor allem für einen gemeinsamen letzten Spieltag, falls das Programm ihn nicht ohnehin so plant. Bei **ungerader Mannschaftszahl** muss am Pflichtspieltag zwangsläufig eine Mannschaft pausieren. Das Original bestraft diese Mannschaft immer; AndiGenerator.NET lässt in einem Pflichtzeitraum mit einem Spiel genau eine fehlende Mannschaft zu, wenn sie in der Woche davor gespielt hat – sie spielt dann also am vorletzten Spieltag. Abschalten lässt sich das in den **Einstellungen** (*Bei ungerader Mannschaftszahl darf am Pflichtspieltag eine Mannschaft spielfrei sein …*); dann gilt die Strafe wie im Original. ### Manuell festgelegte Begegnungen @@ -578,7 +578,7 @@ In der Standardgewichtung haben die Kostenarten schon ein sinnvolles Verhältnis **Länge Spieltage** – Ein Spieltag soll sich über möglichst wenige Tage erstrecken. -**Überlappung letzter Spieltag** und **Länge letzter Spieltag** – dasselbe, nur für den letzten Spieltag. Er soll möglichst kurz sein und so liegen, dass alle Mannschaften zuletzt nur noch ein Spiel haben. +**Überlappung letzter Spieltag** und **Länge letzter Spieltag** – dasselbe, nur für den letzten Spieltag. Er soll möglichst kurz sein und so liegen, dass alle Mannschaften zuletzt nur noch ein Spiel haben. Anders als im Original zählen beim letzten Spieltag alle Überlappungen mit früheren Spieltagen, nicht nur die mit dem ältesten; die Kosten können deshalb etwas höher sein als im alten AndiGenerator. **Vereinsinterne Spiele am Anfang** – Mannschaften desselben Vereins sollen zu Beginn jeder Halbrunde gegeneinander spielen. @@ -646,6 +646,9 @@ Im Bayerischen TTV werden Hallenbelegung und Spiele der Nachbarmannschaften bei **Wird mein Rechner während der Generierung langsam?** Das Programm lässt einen Prozessorkern frei und rechnet mit niedriger Priorität, damit andere Programme bedienbar bleiben. Wer den Rechner in Ruhe lassen möchte, pausiert die Generierung. +**Das Programm meldet einen unerwarteten Fehler.** +Dann ist etwas schiefgegangen, womit das Programm nicht gerechnet hat. Es beendet sich dabei nicht, sondern merkt den aktuellen Plan als **Notfallsicherung** (Sie finden ihn unter **Start mit** und in der Planauswahl der Ansichten) und schreibt die Einzelheiten in die Datei `fehlerprotokoll.txt` im Datenordner (unter Windows `%LOCALAPPDATA%\AndiGenerator.NET`); die Meldung nennt den genauen Pfad. Sie können weiterarbeiten, sicherer ist aber, den Plan zu exportieren oder zu merken und das Programm neu zu starten. Bricht eine laufende Generierung ab, steht das in der Statuszeile, und der beste Plan ist ebenso als Notfallsicherung gemerkt. Helfen Sie, den Fehler zu beheben: Schicken Sie das Fehlerprotokoll mit einer kurzen Beschreibung, was Sie gerade getan haben, über die [Fehlermeldungen auf GitHub](https://github.com/PBuchmann/AndiGenerator.NET/issues). Das Protokoll enthält keine Spielplandaten, kann aber Dateipfade Ihres Rechners nennen. + --- *AndiGenerator.NET – Portierung des AndiGenerators von Andreas Hofmann. Freie Software unter der GNU General Public License Version 3.* diff --git a/Doku/Anleitung.pdf b/Doku/Anleitung.pdf index 8395cf5..6253068 100644 Binary files a/Doku/Anleitung.pdf and b/Doku/Anleitung.pdf differ diff --git a/Doku/MIGRATIONSPLAN.md b/Doku/MIGRATIONSPLAN.md index 9a2ce95..edd421e 100644 --- a/Doku/MIGRATIONSPLAN.md +++ b/Doku/MIGRATIONSPLAN.md @@ -441,10 +441,10 @@ Die Portierung soll sich deutlich vom Original unterscheiden, ohne ihre Herkunft |---|---|---|---| | 1 | PlanTypes Z. 5235/6120 | Hartes Limit 30 Mannschaften (`MAX_MANNSCHAFT`, Exception nur in „Abstand H/A“); Auswärtskoppel-Kosten prüfen nicht und schreiben bei > 31 Mannschaften über das Array hinaus | 1 | | 2 | PlanTypes Z. 6509 | Division durch 0 in „Spielverteilung", wenn ein Team keine Spiele hat | 1 | -| 3 | PlanTypes Z. 8137 | `setDate` löscht `assignedGame` des Wunschtermins ohne Besitzprüfung → Termin gilt ggf. fälschlich als frei | 3 | -| 4 | PlanTypes Z. 3944 | Überlappungskosten letzter Spieltag überschrieben statt summiert | 3 | -| 5 | PlanTypes Z. 1436 | Bei ungerader Teamzahl fehlt im Raster ein Spieltag | 3 | -| 6 | PlanTypes Z. 4695 | Auswärtskoppel-Pfad kann bereits datierte Spiele neu terminieren | 3 | +| 3 | PlanTypes Z. 8137 | `setDate` löscht `assignedGame` des Wunschtermins ohne Besitzprüfung → Termin gilt ggf. fälschlich als frei – **korrigiert** (Entscheidung 11.10.2026, `Korrekturen.WunschterminNurVomEigentuemerFreigeben`) | 3 | +| 4 | PlanTypes Z. 3944 | Überlappungskosten letzter Spieltag überschrieben statt summiert – **korrigiert** (Entscheidung 11.10.2026, `Korrekturen.LetzteUeberlappungSummieren`); die Kosten können dadurch etwas höher sein als im Original | 3 | +| 5 | PlanTypes Z. 1436 | Bei ungerader Teamzahl fehlt im Raster ein Spieltag – **korrigiert** (Entscheidung 11.10.2026, `Korrekturen.VollstaendigesRaster`) | 3 | +| 6 | PlanTypes Z. 4695 | Auswärtskoppel-Pfad kann bereits datierte Spiele neu terminieren – **korrigiert** (Entscheidung 11.10.2026, `Korrekturen.AuswaertskoppelNurOffeneSpiele`) | 3 | | 7 | PlanTypes Z. 8562 | Fehlendes Freitags-Attribut → `false` statt Standard `true`; Ladefehler verschluckt | 2 | | 8 | PlanTypes (CSV) | Lücken in der Nummerierung; undatierte Spiele mit `DateBegin` exportiert | 2 | | 9 | PlanMainThread Z. 199 | `DynamicThreads[0]` statt `[i]` → 1/3-Grenze wirkungslos – **in C# korrigiert** (Entscheidung 27.09.2026, `Insel.DynamischeAnpassen`) | 3 | @@ -471,7 +471,7 @@ Die Portierung soll sich deutlich vom Original unterscheiden, ohne ihre Herkunft | 30 | PlanTypes (CSV) | CSV-Kopfzeile hat 15 Felder, Datenzeilen 16 (abschließendes `;`) – für Byte-Parität beibehalten | – (Eigenheit) | | 31 | Druck (PrintUtil/Main) | Wechsel des Druckers im Druckdialog (z.B. auf „Microsoft Print to PDF“) → „Ausgewählter Drucker ist ungültig“ / „Operation auf ausgewähltem Drucker nicht verfügbar“; gedruckt werden kann nur auf den Windows-Standarddrucker | 2 (entfällt mit PDF-Export, E10) | | 32 | PlanDataObjects/PlanTypes (Import) | Mannschaftsnamen im click-TT-Export teils mit führendem Leerzeichen; das Original trimmt beim Laden, CSV und `.modifications` nutzen den getrimmten Namen | – (Eigenheit, beibehalten) | -| 33 | PlanTypes (`mktMandatoryGames`) | Bei ungerader Mannschaftszahl wird die am Pflichtspieltag zwangsläufig spielfreie Mannschaft immer bestraft (z.B. R3: 100 T); keine Regel „spielfreie Mannschaft spielt am vorletzten Spieltag“ | 3 (fachliche Erweiterung, siehe E14/A5) | +| 33 | PlanTypes (`mktMandatoryGames`) | Bei ungerader Mannschaftszahl wird die am Pflichtspieltag zwangsläufig spielfreie Mannschaft immer bestraft (z.B. R3: 100 T); keine Regel „spielfreie Mannschaft spielt am vorletzten Spieltag“ – **Regel eingebaut** (Entscheidung 11.10.2026): bei ungerader Zahl darf in einem Pflichtzeitraum mit einem Spiel die erste Mannschaft fehlen, die in der Woche davor gespielt hat; Einstellung `SpielfreiAmPflichtspieltag` (Standard an, Attribut `odd-team-free` nur bei „aus“ in der Optionsdatei) | 3 (fachliche Erweiterung, siehe E14/A5) | | 34 | AndiGeneratorMain (`OpenFile`) | Dialog „Benutzereinstellungen“: **„Nein, die Standardeinstellungen verwenden“ setzt die Optionen zurück und speichert sie sofort**, ohne Warnung und ohne Sicherung. Die gespeicherten Einstellungen der Staffel sind danach verloren (am 27.09.2026 bei den Referenzmessungen für 19 Staffeln passiert). C#: nie stillschweigend überschreiben; „Standard nur für diese Sitzung“ anbieten, vor dem Überschreiben sichern | 2 | | 35 | AndiGeneratorMain (`GetCurrentPlanDirectory`), AndiGeneratorMain (`ButtonSaveClick`) | Staffelordner = `Name + ' ' + Jahr(Beginn)`: ein Plan ohne Namen/Beginn (z. B. „Neu“) landet im Ordner „ 1899“ (vorhanden). Gemerkte Pläne speichern keine Spiellokale (Namensvergleich im Dialog „Plan merken“: getrimmt, ohne Groß-/Kleinschreibung, mit Rückfrage vor dem Überschreiben). C#: Verhalten beim Lesen/Schreiben übernommen; Ordner ohne Staffelnamen später abfangen (ein führendes Leerzeichen im Namen ist unter Windows problematisch, OneDrive synchronisiert solche Namen z. B. nicht) | 1 | @@ -526,16 +526,16 @@ Funktional ist der Umfang des Originals portiert und um Automodus, Leitstand, ge **Muss vor 1.0** -- [ ] **Stand abschließen:** Branch `feature/automodus-uebergang` committen und zusammenführen; Bildschirmfotos und `Doku/Anleitung.pdf` neu erzeugen (Leitstand, Start/Pause/Stopp, Spielplandaten-Seiten haben sich geändert). -- [ ] **Absturzschutz:** globaler Fehlerfänger (`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException`, Fehler im UI-Thread): verständliche Meldung, Protokolldatei im Datenordner, vorher den laufenden Plan als gesicherten Plan ablegen. -- [ ] **Klasse-3-Befunde entscheiden (E9):** #3, #4, #5, #6, #9, #10 und #33 – je Befund korrigieren (ggf. als Schalter) oder bewusst wie im Original lassen; Entscheidung in Abschnitt 6 eintragen. -- [ ] **Signierung entscheiden (E12):** Zertifikat für Windows (SmartScreen-Warnung) bzw. Notarisierung für macOS – oder ein klarer Hinweis auf der Download-Seite. +- [x] **Stand abschließen:** *(erledigt 11.10.2026, PR #11)* Branch `feature/automodus-uebergang` committen und zusammenführen; Bildschirmfotos und `Doku/Anleitung.pdf` neu erzeugen (Leitstand, Start/Pause/Stopp, Spielplandaten-Seiten haben sich geändert). +- [x] **Absturzschutz:** *(erledigt 11.10.2026: `Fehlerprotokoll`, `Fehlerbehandlung`, `NotfallSichern`; statt den gesicherten Plan anzutasten, wird der Plan als gemerkter Plan „Notfallsicherung“ abgelegt)* globaler Fehlerfänger (`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException`, Fehler im UI-Thread): verständliche Meldung, Protokolldatei im Datenordner, vorher den laufenden Plan als gesicherten Plan ablegen. +- [x] **Klasse-3-Befunde entscheiden (E9):** *(entschieden 11.10.2026: #3, #4, #5, #6 korrigiert, #33 als Einstellung; #9 und #10 waren schon korrigiert. Erste Messung 11.10.2026 mit `korrekturvergleich.cmd`, siehe Änderungsprotokoll; eine längere Messung der Suchkorrekturen steht noch aus)* #3, #4, #5, #6, #9, #10 und #33 – je Befund korrigieren (ggf. als Schalter) oder bewusst wie im Original lassen; Entscheidung in Abschnitt 6 eintragen. +- [x] **Signierung entscheiden (E12):** *(entschieden 11.10.2026: Die SignPath Foundation hat abgelehnt – zu wenig öffentliche Sichtbarkeit, erneuter Antrag später möglich. Die Signierung darf derzeit nichts kosten, deshalb erscheint 1.0 **unsigniert** mit dem Hinweis zur SmartScreen-Warnung in Anleitung und README und mit den Herkunftsnachweisen von GitHub. Geprüfte, aber kostenpflichtige Wege: Azure Artifact Signing, ca. 9,99 $/Monat, für Einzelpersonen außerhalb USA/Kanada unklar; Certum Open Source, ab 49 €; OV-Zertifikat. Kostenlos wäre später der Microsoft Store mit MSIX-Paket – Microsoft signiert dann selbst, allerdings ohne Velopack-Updates)* **Sollte vor 1.0** - [ ] **Beta mit echten Staffelleitern (Phase 9):** zwei bis drei Staffeln parallel mit Original und neuem Programm planen, Befunde sammeln. -- [ ] **Sprache fest auf Deutsch:** `CultureInfo.DefaultThreadCurrentCulture`/`UICulture` beim Start auf de-DE setzen und auf einem englisch eingestellten Windows prüfen (Monatsnamen der Datumsauswahl, Zahlenformate, Standardtexte; vgl. Platzhalter „hour“/„minute“). -- [ ] **Änderungsprotokoll für Anwender:** `CHANGELOG.md` bzw. Release Notes je GitHub-Release, mit 1.0 eine Übersicht „Was ist anders als im Original“. +- [x] **Sprache fest auf Deutsch:** *(erledigt 11.10.2026 in `Program.Main`; Prüfung auf englischem Windows steht noch aus)* `CultureInfo.DefaultThreadCurrentCulture`/`UICulture` beim Start auf de-DE setzen und auf einem englisch eingestellten Windows prüfen (Monatsnamen der Datumsauswahl, Zahlenformate, Standardtexte; vgl. Platzhalter „hour“/„minute“). +- [x] **Änderungsprotokoll für Anwender:** *(erledigt 11.10.2026: `CHANGELOG.md`; offen: Abschnitt der Version automatisch als Release Notes ins GitHub-Release übernehmen)* `CHANGELOG.md` bzw. Release Notes je GitHub-Release, mit 1.0 eine Übersicht „Was ist anders als im Original“. - [ ] **Testabdeckung der Oberfläche** neu messen (zuletzt 01.10.2026: gesamt 92,8 %, UI 76 %) und die neuen Abläufe absichern (Drag & Drop, Kalender, Sicherungsdialog, Einrichtung). **Abrundung (auch nach 1.0 möglich)** @@ -801,3 +801,7 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa | 11.10.2026 | Anleitung erweitert: eigene Abschnitte 4.1 *Der Leitstand* (Aufbau, Tabelle der Inselzustände nach `Schaubild.Zustand`, Automodus-Gewichte) und 4.2 *Der Verlauf*; neue Bildschirmfotos 20-leitstand-kosten, 21-verlauf, 22-sicherung (Vergleich beim zweiten Sichern), 23-spielfreie-tage, 24-setzliste, 25-wunschtermine (`Bildschirmfotos.cs`) | | 11.10.2026 | Nach Durchsicht der Bildschirmfotos: deutsche Platzhalter schon in `App.Initialize` (galten sonst nicht ohne Bildschirm), Koordinator und Automodus-Pult im Leitstand breiter (Text war abgeschnitten), Legende der Wunschtermine bricht um (Seite war breiter als der Dialog), Verlauf zeigt nach Wechsel von der Kostenoptimierung in den Automodus zusätzlich die Kosten (Zeit davor war leer) | | 11.10.2026 | Leitstand: Schaubild und Inselkarte widersprachen sich („kommt voran“ gegenüber „beginnt gleich neu“). Jetzt geht im Schaubild „beginnt gleich neu“ vor (schlechteste Insel über der Neustart-Grenze, auch nach frischer Verbesserung), wie auf der Karte; Test `Die_schlechteste_Insel_ueber_der_Grenze_beginnt_gleich_neu_auch_wenn_sie_eben_verbessert_hat` | +| 11.10.2026 | Absturzschutz: unerwartete Fehler (Oberfläche, Programm, Hintergrundaufgaben, Abbruch eines Rechenthreads der Generierung) kommen in `fehlerprotokoll.txt` im Datenordner (ab 1 MB beginnt ein neues, das alte bleibt als `fehlerprotokoll.alt.txt`); bei Fehlern auf der Oberfläche und Abbrüchen wird der Plan als gemerkter Plan „Notfallsicherung“ abgelegt und einmal eine Meldung gezeigt, das Programm läuft weiter. Kultur der Anwendung fest de-DE (`Program.Main`) | +| 11.10.2026 | Klasse-3-Befunde entschieden (E9): #3, #4, #5, #6 werden korrigiert (`Domain.Optionen.Korrekturen`, Standard `Alle`, in Kern- und Referenzmodell gleich, damit die Differenztests greifen), #33 als Einstellung „Bei ungerader Mannschaftszahl darf am Pflichtspieltag eine Mannschaft spielfrei sein“ (Standard an). `Berechnungsoptionen.Original` rechnet wie das Original (Paritätstests); `andigen optimieren --original` zum Vergleich. Mit der Regel hängt der Kostenwert Pflichtspieltage jeder Mannschaft von allen ab; der Cache rechnet diese Kostenart dann bei jeder Änderung für alle Mannschaften | +| 11.10.2026 | Messung der Korrekturen (`korrekturvergleich.cmd`: drei Staffeln mit ungerader Mannschaftszahl, je Variante 2 × 120 s Kostenoptimierung aus leerem Plan, alle Pläne in beiden Kostenmodellen bewertet; Mittel im neuen Kostenmodell). **R3** (9 Mannschaften, Pflichtspieltage): neu 50 T, Original 98 T – das Original weicht der Pflichtspieltag-Strafe aus, indem eine Mannschaft zwei Spiele in einer Woche bekommt (je 40 T), mit der Regel spielt sie stattdessen in der Woche davor. **R19** (11): neu 481 T, Original 532 T. **3. Kreisklasse** (13): neu 438 T, Original 377 T. Die Streuung innerhalb einer Variante (z. B. 524 T / 352 T) ist größer als die Unterschiede; für die Suchkorrekturen #3, #5, #6 ist damit weder ein Vorteil noch ein Nachteil belegt. Die summierte Überlappung des letzten Spieltags (#4) änderte bei keinem der zwölf Pläne den Wert. Geschwindigkeit: bei R3 mit der Pflichtspieltag-Regel rund 3 % weniger Pläne/s (Kostenart wird für alle Mannschaften neu gerechnet), sonst gleich | +| 11.10.2026 | E12 Signierung: SignPath Foundation hat abgelehnt; 1.0 erscheint unsigniert (kostenfrei), `CODE_SIGNING.md` angepasst. Release-Workflow übernimmt den Abschnitt der Version aus `CHANGELOG.md` als Release Notes | diff --git a/README.md b/README.md index 76753c9..ccf31b7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ AndiGenerator.NET ist die C#/.NET-Portierung des **AndiGenerators** von **Andrea ## Anleitung - [Anleitung lesen](Doku/ANLEITUNG.md) (auch als [PDF](Doku/Anleitung.pdf), im Programm unter **Anleitung**) +- [Änderungen je Version](CHANGELOG.md) ## Installation diff --git a/korrekturvergleich.cmd b/korrekturvergleich.cmd new file mode 100644 index 0000000..066c9c6 --- /dev/null +++ b/korrekturvergleich.cmd @@ -0,0 +1,36 @@ +@echo off +rem Vergleich der Korrekturen (Klasse-3-Befunde, Regel fuer den Pflichtspieltag) mit dem Original-Verhalten: +rem drei Staffeln mit ungerader Mannschaftszahl, je Variante zwei Laeufe zu 120 s Kostenoptimierung aus leerem Plan +rem (zusammen gut 25 Minuten). Danach werden alle Plaene in beiden Kostenmodellen bewertet. Ergebnisse im Ordner +rem korrekturvergleich (wird von Claude gelesen). Vorher bauen.cmd ausfuehren und den Rechner moeglichst nicht belasten. +chcp 65001 >nul +cd /d "%~dp0" +set "EXE=%LOCALAPPDATA%\AndiGenerator.NET\artifacts\bin\AndiGenerator.Cli\release\andigen.exe" +set "ZIEL=%~dp0korrekturvergleich" +if not exist "%EXE%" ( + echo andigen.exe nicht gefunden - bitte zuerst bauen.cmd ausfuehren. + pause + exit /b 1 +) +if not exist "%ZIEL%" mkdir "%ZIEL%" +call :staffel R3 "%~dp0testdaten\referenz\eingabe\R3_Damen_Bezirksoberliga_Rheinhessen (1).xml" +call :staffel R19 "%~dp0testdaten\referenz\eingabe\R19_1__Kreisklasse (7).xml" +call :staffel KK3 "%~dp0testdaten\referenz\perf\3__Kreisklasse (8).xml" +echo. +echo Fertig. Ergebnisse im Ordner korrekturvergleich - Claude kann sie jetzt lesen. +echo ===== ENDE %DATE% %TIME% > "%ZIEL%\fertig.txt" +pause +goto :eof + +:staffel +for %%l in (1 2) do ( + echo. + echo ======== %1 neu, Lauf %%l ======== + "%EXE%" optimieren %2 --leer --sekunden 120 --intervall 30 --plan "%ZIEL%\%1-neu-%%l.xml" --ausgabe "%ZIEL%\%1-neu-%%l.txt" + echo. + echo ======== %1 original, Lauf %%l ======== + "%EXE%" optimieren %2 --leer --sekunden 120 --intervall 30 --original --plan "%ZIEL%\%1-original-%%l.xml" --ausgabe "%ZIEL%\%1-original-%%l.txt" +) +"%EXE%" bewerten %2 "%ZIEL%\%1-neu-1.xml" "%ZIEL%\%1-neu-2.xml" "%ZIEL%\%1-original-1.xml" "%ZIEL%\%1-original-2.xml" --ausgabe "%ZIEL%\%1-bewertung-neu.txt" +"%EXE%" bewerten %2 "%ZIEL%\%1-neu-1.xml" "%ZIEL%\%1-neu-2.xml" "%ZIEL%\%1-original-1.xml" "%ZIEL%\%1-original-2.xml" --original --ausgabe "%ZIEL%\%1-bewertung-original.txt" +goto :eof diff --git a/src/AndiGenerator.Application/Fehlerprotokoll.cs b/src/AndiGenerator.Application/Fehlerprotokoll.cs new file mode 100644 index 0000000..df4ca14 --- /dev/null +++ b/src/AndiGenerator.Application/Fehlerprotokoll.cs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + +namespace AndiGenerator.Application; + +/// +/// Protokoll unerwarteter Fehler im eigenen Datenordner (fehlerprotokoll.txt): Zeitpunkt, wobei der Fehler auftrat, +/// Programmversion, Betriebssystem und der vollständige Fehler. Das Original kennt so etwas nicht; dort beendet ein +/// unerwarteter Fehler das Programm ohne Spur. Die Datei bleibt auf dem Rechner, bis der Anwender sie selbst weitergibt. +/// +public static class Fehlerprotokoll +{ + /// Dateiname des Protokolls im eigenen Datenordner. + public const string Dateiname = "fehlerprotokoll.txt"; + + /// Name der Datei, in die ein zu groß gewordenes Protokoll verschoben wird. + public const string AlterDateiname = "fehlerprotokoll.alt.txt"; + + /// Ab dieser Größe (Bytes) beginnt ein neues Protokoll; das alte bleibt als . + public const long Hoechstgroesse = 1_000_000; + + /// Wohin Anwender Fehler melden. + public const string Fehlermeldungsadresse = "https://github.com/PBuchmann/AndiGenerator.NET/issues"; + + /// Hängt einen Fehler an das Protokoll an. + /// Eigener Datenordner (). + /// Der Fehler. + /// Wobei er auftrat, z. B. „Oberfläche“ oder „Generierung“. + /// Pfad des Protokolls. + /// Das Protokoll ließ sich nicht schreiben. + /// Kein Schreibrecht im Datenordner. + public static string Schreiben(string basis, Exception fehler, string wobei) + { + ArgumentException.ThrowIfNullOrEmpty(basis); + ArgumentNullException.ThrowIfNull(fehler); + Directory.CreateDirectory(basis); + string pfad = Path.Combine(basis, Dateiname); + if (File.Exists(pfad) && new FileInfo(pfad).Length > Hoechstgroesse) + { + File.Move(pfad, Path.Combine(basis, AlterDateiname), overwrite: true); + } + + string version = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "unbekannt"; + string eintrag = string.Create( + CultureInfo.InvariantCulture, + $"===== {DateTime.Now:yyyy-MM-dd HH:mm:ss} · {wobei} · Version {version} · {RuntimeInformation.OSDescription}{Environment.NewLine}{fehler}{Environment.NewLine}{Environment.NewLine}"); + File.AppendAllText(pfad, eintrag, Encoding.UTF8); + return pfad; + } + + /// Meldung nach einem unerwarteten Fehler: was geschah, was gesichert wurde und wo das Protokoll liegt. + /// Der Fehler. + /// Name des gemerkten Plans mit der Notfallsicherung oder null. + /// Pfad des Fehlerprotokolls oder null. + /// Der Text. + public static string Meldung(Exception fehler, string? gesichert, string? protokoll) + { + ArgumentNullException.ThrowIfNull(fehler); + var text = new StringBuilder(); + text.Append("Es ist ein unerwarteter Fehler aufgetreten: ").Append(fehler.Message).AppendLine().AppendLine(); + if (gesichert is not null) + { + text.Append("Der aktuelle Plan wurde als gemerkter Plan „").Append(gesichert) + .Append("“ gesichert. Sie finden ihn unter „Start mit“ und in der Planauswahl der Ansichten.").AppendLine().AppendLine(); + } + + if (protokoll is not null) + { + text.Append("Einzelheiten stehen in ").Append(protokoll).Append(". Bitte schicken Sie diese Datei mit einer kurzen Beschreibung, ") + .Append("was Sie gerade getan haben, an die Entwickler (").Append(Fehlermeldungsadresse).Append(").").AppendLine().AppendLine(); + } + + text.Append("Sie können weiterarbeiten. Sicherer ist es, den Plan zu exportieren oder zu merken und das Programm neu zu starten."); + return text.ToString(); + } +} diff --git a/src/AndiGenerator.Application/Optionsabweichungen.cs b/src/AndiGenerator.Application/Optionsabweichungen.cs index b927fbd..06a912e 100644 --- a/src/AndiGenerator.Application/Optionsabweichungen.cs +++ b/src/AndiGenerator.Application/Optionsabweichungen.cs @@ -36,6 +36,12 @@ public static IReadOnlyList Meldungen(Berechnungsoptionen optionen, Staf ergebnis.Add(string.Empty); } + if (!optionen.SpielfreiAmPflichtspieltag) + { + ergebnis.Add("Bei ungerader Mannschaftszahl wird die am Pflichtspieltag spielfreie Mannschaft bestraft (wie im Original)"); + ergebnis.Add(string.Empty); + } + string? runde = optionen.Rundenplanung switch { Rundenplanung.Halbrunde => "Halbrunde wird generiert", diff --git a/src/AndiGenerator.Desktop/Program.cs b/src/AndiGenerator.Desktop/Program.cs index 421fa99..207267f 100644 --- a/src/AndiGenerator.Desktop/Program.cs +++ b/src/AndiGenerator.Desktop/Program.cs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.UI; using Avalonia; using Velopack; @@ -18,6 +19,13 @@ public static void Main(string[] args) { // Velopack erledigt hier Installation, Deinstallation und das Einspielen von Updates und beendet dann selbst. VelopackApp.Build().Run(); + + // Die Oberfläche ist deutsch – auch auf einem anders eingestellten System (Monatsnamen, Zahlen, Datumsformate). + var deutsch = CultureInfo.GetCultureInfo("de-DE"); + CultureInfo.DefaultThreadCurrentCulture = deutsch; + CultureInfo.DefaultThreadCurrentUICulture = deutsch; + CultureInfo.CurrentCulture = deutsch; + CultureInfo.CurrentUICulture = deutsch; BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } diff --git a/src/AndiGenerator.Domain/Optionen/Berechnungsoptionen.cs b/src/AndiGenerator.Domain/Optionen/Berechnungsoptionen.cs index 6877915..e370d34 100644 --- a/src/AndiGenerator.Domain/Optionen/Berechnungsoptionen.cs +++ b/src/AndiGenerator.Domain/Optionen/Berechnungsoptionen.cs @@ -52,6 +52,22 @@ public sealed record Berechnungsoptionen( Mitte1: null, Mitte2: null); + /// Wie das Original: Standardeinstellungen ohne die Korrekturen und ohne die Regel für den Pflichtspieltag. + public static Berechnungsoptionen Original => Standard with { Korrekturen = Korrekturen.Keine, SpielfreiAmPflichtspieltag = false }; + + /// + /// Holt die Korrekturen an Fehlern des Originals in Suche und Kostenrechnung (nicht in der Optionsdatei; Standard: alle). + /// + public Korrekturen Korrekturen { get; init; } = Korrekturen.Alle; + + /// + /// Holt einen Wert, der angibt, ob bei ungerader Mannschaftszahl in einem Pflichtzeitraum mit einem Spiel genau eine + /// Mannschaft fehlen darf, wenn sie in der Woche davor gespielt hat (Befund #33, MIGRATIONSPLAN E14/A5; Attribut + /// odd-team-free im Element mandatory-games, Standard true). Das Original bestraft diese + /// zwangsläufig spielfreie Mannschaft immer. + /// + public bool SpielfreiAmPflichtspieltag { get; init; } = true; + /// Gewichtung einer Kostenart für alle Mannschaften. /// Kostenart. /// Die Gewichtung dieser Kostenart. diff --git a/src/AndiGenerator.Domain/Optionen/Korrekturen.cs b/src/AndiGenerator.Domain/Optionen/Korrekturen.cs new file mode 100644 index 0000000..94b9867 --- /dev/null +++ b/src/AndiGenerator.Domain/Optionen/Korrekturen.cs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Optionen; + +/// +/// Korrekturen an Fehlern des Originals in Suche und Kostenrechnung (Klasse-3-Befunde, MIGRATIONSPLAN E9, Entscheidung +/// 11.10.2026). Jede lässt sich einzeln abschalten; rechnet und sucht genau wie das Original +/// (Paritätstests, Vergleichsmessungen). +/// +/// +/// Befund #3 (TGame.setDate): Ein Spiel gibt beim Umsetzen nur einen Wunschtermin frei, den es selbst belegt. Im +/// Original kann das Zurücksetzen eines Spiels den Termin eines anderen Spiels als frei markieren. +/// +/// +/// Befund #4 (LastKostenOverlap): Die Überlappungen des letzten Spieltags werden summiert. Das Original behält nur +/// die des ältesten überlappenden Spieltags (den größten Anteil). +/// +/// +/// Befund #5 (CreateRasterOneRound): Bei ungerader Mannschaftszahl legt das Raster alle n Spieltage des +/// Kreisverfahrens an. Das Original legt einen zu wenig an; dessen Spiele setzt es danach zufällig. +/// +/// +/// Befund #6 (FillTermineIntern): Beim Füllen werden nur Spiele ohne Termin terminiert, auch über den Weg der +/// Auswärtskoppel. Das Original kann dort ein schon terminiertes Spiel neu terminieren (bei der Doppelrunde auch in eine +/// andere Hauptrunde). +/// +public sealed record Korrekturen( + bool WunschterminNurVomEigentuemerFreigeben, + bool LetzteUeberlappungSummieren, + bool VollstaendigesRaster, + bool AuswaertskoppelNurOffeneSpiele) +{ + /// Alle Korrekturen (Standard). + public static Korrekturen Alle { get; } = new(true, true, true, true); + + /// Keine Korrektur: wie das Original. + public static Korrekturen Keine { get; } = new(false, false, false, false); +} diff --git a/src/AndiGenerator.Engine/Kern/KernDefinition.cs b/src/AndiGenerator.Engine/Kern/KernDefinition.cs index 032b167..f9bc6f0 100644 --- a/src/AndiGenerator.Engine/Kern/KernDefinition.cs +++ b/src/AndiGenerator.Engine/Kern/KernDefinition.cs @@ -29,6 +29,8 @@ public KernDefinition(RefPlan plan) Rundenplanung = Optionen.Rundenplanung; Doppelrunde = Optionen.Doppelrunde; FreitagZaehltZumWochenende = Optionen.FreitagZaehltZumWochenende; + Korrekturen = Optionen.Korrekturen; + SpielfreiAmPflichtspieltag = Optionen.SpielfreiAmPflichtspieltag && plan.Mannschaften.Count % 2 == 1; // Gleichheitsklassen der click-TT-Ids (das Original vergleicht Spiele und Paarungen über die Id). var idKlassen = new Dictionary(StringComparer.Ordinal); @@ -263,6 +265,21 @@ public KernDefinition(RefPlan plan) public bool FreitagZaehltZumWochenende { get; } + /// Korrekturen an Fehlern des Originals (Klasse-3-Befunde). + public Korrekturen Korrekturen { get; } + + /// + /// Bei ungerader Mannschaftszahl darf in einem Pflichtzeitraum mit einem Spiel eine Mannschaft fehlen, die in der Woche + /// davor gespielt hat (Befund #33); nur gesetzt, wenn die Einstellung an und die Mannschaftszahl ungerade ist. + /// + public bool SpielfreiAmPflichtspieltag { get; } + + /// + /// Spieltage des Rasters je Runde: beim Kreisverfahren n − 1 bei gerader, n bei ungerader Mannschaftszahl. Das Original + /// nimmt immer n − 1 (Befund #5). + /// + public int RasterSpieltage => N % 2 == 1 && Korrekturen.VollstaendigesRaster ? N : N - 1; + public RefRunde[] Runden { get; } public bool[] RundeZuPlanen { get; } diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs index d650a87..22b214e 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs @@ -69,7 +69,9 @@ private void AenderungenErmitteln() /// Original CalculateMannschaftsKosten mit Cache: nur geänderte Mannschaften werden neu gerechnet. private double MannschaftsKostenGecacht(MannschaftsKostenart art) { - bool alle = !artBerechnet[(int)art]; + // Mit der Regel für den Pflichtspieltag hängen die Kosten jeder Mannschaft von allen ab (Befund #33). + bool alle = !artBerechnet[(int)art] + || (art == MannschaftsKostenart.Pflichtspieltage && D.SpielfreiAmPflichtspieltag && Array.IndexOf(geaendert, true) >= 0); bool[] betroffen = art is MannschaftsKostenart.Hallenbelegung or MannschaftsKostenart.ParalleleSpiele ? vereinGeaendert : geaendert; double ergebnis = 0; for (int t = 0; t < D.N; t++) diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs index 919973b..234dabe 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs @@ -246,7 +246,7 @@ private void Pflichtspieltage(int t, int k, double faktor) } } - if (anzahl < zeit.AnzahlSpiele) + if (anzahl < zeit.AnzahlSpiele && !(D.SpielfreiAmPflichtspieltag && zeit.AnzahlSpiele == 1 && SpielfreieMannschaft(zeit) == t)) { KostenAnzahl[k] += zeit.AnzahlSpiele - anzahl; } @@ -257,6 +257,51 @@ private void Pflichtspieltage(int t, int k, double faktor) KostenAlle[k] = alle; } + /// + /// Befund #33: Bei ungerader Mannschaftszahl ist in einem Pflichtzeitraum mit einem Spiel eine Mannschaft zwangsläufig + /// spielfrei. Zulässig ist das für die erste Mannschaft, die im Zeitraum nicht, aber in der Woche davor gespielt hat. + /// + /// Die Mannschaft oder −1. + private int SpielfreieMannschaft(RefPflichtzeit zeit) + { + for (int u = 0; u < D.N; u++) + { + if (SpieleZwischen(u, zeit.Von, zeit.Bis) == 0 && SpieleZwischen(u, zeit.Von - 7, zeit.Von - 1) > 0) + { + return u; + } + } + + return -1; + } + + /// Terminierte Spiele einer Mannschaft zwischen zwei Tagen (einschließlich). + private int SpieleZwischen(int t, double von, double bis) + { + int anzahl = 0; + int[] liste = Spiele[t]; + for (int i = ErsterIndexAb(t, von); i < SpieleAnzahl[t]; i++) + { + int s = liste[i]; + if (Datum[s] == 0.0) + { + continue; + } + + if (Tag[s] > bis) + { + break; + } + + if (Tag[s] >= von) + { + anzahl++; + } + } + + return anzahl; + } + /// Original TGameList.getFirstGameRefIndexByDate. private int ErsterIndexAb(int t, double datum) { diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Optimierung.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Optimierung.cs index 565a108..36650eb 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Optimierung.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Optimierung.cs @@ -31,6 +31,12 @@ public void Zuruecksetzen() { if (!Fest[s]) { + // Befund #3: Gehört der Wunschtermin inzwischen einem anderen Spiel, bleibt er dessen Termin. + if (D.Korrekturen.WunschterminNurVomEigentuemerFreigeben && Wunsch[s] >= 0 && WunschSpiel[Wunsch[s]] != s) + { + Wunsch[s] = -1; + } + SetzeDatum(s, gemerktDatum[s], gemerktOptionen[s], gemerktMaxHeim[s], false, gemerktNichtNotwendig[s], gemerktWunsch[s]); } } @@ -175,12 +181,13 @@ public void RasterErzeugen(Zufall zufall) } Mischen(reihenfolge, n, zufall); - for (int i = 0; i < n - 1; i++) + int spieltage = D.RasterSpieltage; + for (int i = 0; i < spieltage; i++) { spieltagIndexe[i] = i; } - Mischen(spieltagIndexe, n - 1, zufall); + Mischen(spieltagIndexe, spieltage, zufall); for (int i = 0; i < D.Runden.Length; i++) { RasterEinerRunde(i, i % 2 == 0, zufall); @@ -353,7 +360,9 @@ private void TermineFuellenIntern(bool alleZweitzeitenGueltig, Zufall zufall) { int i = zufall.Zahl(anzahlOffen); int s = offen[i]; - if (!NichtNotwendig[s]) + + // Befund #6: Ein Spiel, das inzwischen über die Auswärtskoppel einen Termin bekam, wird nicht neu terminiert. + if (!NichtNotwendig[s] && (!D.Korrekturen.AuswaertskoppelNurOffeneSpiele || Datum[s] == 0.0)) { SpielFuellen(s, alleZweitzeitenGueltig, zufall); } @@ -388,6 +397,11 @@ private void SpielFuellen(int s, bool alleZweitzeitenGueltig, Zufall zufall) } int zweites = abhaengig[zufall.Zahl(abhaengig.Length)]; + if (D.Korrekturen.AuswaertskoppelNurOffeneSpiele && (Datum[zweites] != 0.0 || NichtNotwendig[zweites])) + { + return; + } + int zweitesHeim = Heim[zweites]; int start = D.WunschStart[zweitesHeim]; int passend = 0; @@ -459,7 +473,7 @@ private void RasterEinerRunde(int runde, bool rueckrunde, Zufall zufall) double[] alle = D.RundenMontage[runde]; int anzahlMontage = alle.Length; Array.Copy(alle, montage, anzahlMontage); - while (anzahlMontage > D.N - 1) + while (anzahlMontage > D.RasterSpieltage) { int index = zufall.Zahl(anzahlMontage); Array.Copy(montage, index + 1, montage, index, anzahlMontage - index - 1); diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Plankosten.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Plankosten.cs index 27c1007..27546f3 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Plankosten.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Plankosten.cs @@ -180,7 +180,8 @@ public RefPlan.Spieltagskosten SpieltagsKosten() double wert = abstand * faktor; if (spieltage - 1 == i) { - letzteUeberlappung = wert * 20.0; + // Befund #4: Das Original behält nur den Wert des ältesten überlappenden Spieltags. + letzteUeberlappung = optionen.Korrekturen.LetzteUeberlappungSummieren ? letzteUeberlappung + (wert * 20.0) : wert * 20.0; } ueberlappung += wert; diff --git a/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs b/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs index 552260e..713f5d9 100644 --- a/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs +++ b/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs @@ -226,7 +226,7 @@ private void Pflichtspieltage(RefPlan plan, MannschaftsKostenart art, RefMannsch } } - if (anzahl < zeit.AnzahlSpiele) + if (anzahl < zeit.AnzahlSpiele && !(zeit.AnzahlSpiele == 1 && ReferenceEquals(plan.SpielfreieMannschaft(zeit), this))) { kosten.Werte[(int)art].Anzahl += zeit.AnzahlSpiele - anzahl; if (meldungen) diff --git a/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs b/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs index 4ed62f8..4667143 100644 --- a/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs +++ b/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs @@ -365,6 +365,35 @@ public int ParalleleSpiele(double datum, RefPlan plan) return anzahl; } + /// Terminierte Spiele dieser Mannschaft zwischen zwei Tagen (einschließlich). + /// Erster Tag. + /// Letzter Tag. + /// Die Anzahl. + public int SpieleZwischen(double von, double bis) + { + int anzahl = 0; + for (int i = ErsterIndexAb(von); i < Spiele.Count; i++) + { + if (!Spiele[i].DatumGueltig) + { + continue; + } + + int tag = DelphiDatum.Trunc(Spiele[i].Datum); + if (tag > bis) + { + break; + } + + if (tag >= von) + { + anzahl++; + } + } + + return anzahl; + } + /// Original TAuswaertsKoppelCachedTeams.AddTeamIdToTeamId. private static void PartnerHinzufuegen(Dictionary> partner, string von, string zu) { diff --git a/src/AndiGenerator.Engine/Referenz/RefPlan.Kosten.cs b/src/AndiGenerator.Engine/Referenz/RefPlan.Kosten.cs index b441c90..5d818a3 100644 --- a/src/AndiGenerator.Engine/Referenz/RefPlan.Kosten.cs +++ b/src/AndiGenerator.Engine/Referenz/RefPlan.Kosten.cs @@ -129,6 +129,22 @@ public double VereinsinterneSpieleAmAnfangKosten() return Math.Min(MaxKostenOhneHartenFehler, wert); } + /// + /// Befund #33: Bei ungerader Mannschaftszahl ist in einem Pflichtzeitraum mit einem Spiel eine Mannschaft zwangsläufig + /// spielfrei. Zulässig ist das für die erste Mannschaft, die im Zeitraum nicht, aber in der Woche davor gespielt hat. + /// + /// Der Pflichtzeitraum. + /// Die Mannschaft oder null (auch, wenn die Regel nicht gilt). + public RefMannschaft? SpielfreieMannschaft(RefPflichtzeit zeit) + { + if (!Optionen.SpielfreiAmPflichtspieltag || Mannschaften.Count % 2 == 0) + { + return null; + } + + return Mannschaften.FirstOrDefault(m => m.SpieleZwischen(zeit.Von, zeit.Bis) == 0 && m.SpieleZwischen(zeit.Von - 7, zeit.Von - 1) > 0); + } + /// Original CalculateSpielTagKosten. public Spieltagskosten SpieltagsKosten() { @@ -166,7 +182,8 @@ public Spieltagskosten SpieltagsKosten() double wert = abstand * faktor; if (spieltage - 1 == i) { - letzteUeberlappung = wert * 20.0; + // Befund #4: Das Original behält nur den Wert des ältesten überlappenden Spieltags. + letzteUeberlappung = Optionen.Korrekturen.LetzteUeberlappungSummieren ? letzteUeberlappung + (wert * 20.0) : wert * 20.0; } ueberlappung += wert; diff --git a/src/AndiGenerator.Engine/Referenz/RefPlan.Optimierung.cs b/src/AndiGenerator.Engine/Referenz/RefPlan.Optimierung.cs index d5d4633..8dcdd69 100644 --- a/src/AndiGenerator.Engine/Referenz/RefPlan.Optimierung.cs +++ b/src/AndiGenerator.Engine/Referenz/RefPlan.Optimierung.cs @@ -53,9 +53,10 @@ public void Merken() /// Original TransferRefDateToDate (die Spiellisten der Mannschaften werden wie im Original nicht neu sortiert). public void Zuruecksetzen() { + bool nurEigene = Optionen.Korrekturen.WunschterminNurVomEigentuemerFreigeben; foreach (RefSpiel spiel in SpieleErlaubt) { - spiel.Zuruecksetzen(); + spiel.Zuruecksetzen(nurEigene); } } @@ -159,7 +160,7 @@ public void RasterErzeugen(Zufall zufall) } } - var spieltagIndexe = Enumerable.Range(0, mannschaften.Count - 1).ToList(); + var spieltagIndexe = Enumerable.Range(0, RasterSpieltage()).ToList(); for (int i = 0; i <= 100; i++) { int i1 = zufall.Zahl(spieltagIndexe.Count); @@ -274,7 +275,9 @@ private void TermineFuellenIntern(bool alleZweitzeitenGueltig, Zufall zufall) { int i = zufall.Zahl(offen.Count); RefSpiel spiel = offen[i]; - if (spiel.NichtNotwendig) + + // Befund #6: Ein Spiel, das inzwischen über die Auswärtskoppel einen Termin bekam, wird nicht neu terminiert. + if (spiel.NichtNotwendig || (Optionen.Korrekturen.AuswaertskoppelNurOffeneSpiele && spiel.Datum != 0.0)) { offen.RemoveAt(i); continue; @@ -288,10 +291,11 @@ private void TermineFuellenIntern(bool alleZweitzeitenGueltig, Zufall zufall) if (abhaengig is { Count: > 0 }) { RefSpiel zweites = abhaengig[zufall.Zahl(abhaengig.Count)]; + bool zweitesOffen = !Optionen.Korrekturen.AuswaertskoppelNurOffeneSpiele || (zweites.Datum == 0.0 && !zweites.NichtNotwendig); List indexe = Enumerable.Range(0, zweites.Heim.Wunschtermine.Count) .Where(k => Math.Abs(DelphiDatum.Trunc(spiel.Datum) - DelphiDatum.Trunc(zweites.Heim.Wunschtermine[k].Datum)) <= 1) .ToList(); - if (indexe.Count > 0) + if (zweitesOffen && indexe.Count > 0) { TerminFuellen(zweites, indexe, alleZweitzeitenGueltig, zufall); } @@ -352,11 +356,18 @@ private void UngueltigeZweitterminSpieleEntfernen() } } + /// + /// Spieltage des Rasters je Runde: beim Kreisverfahren n − 1 bei gerader, n bei ungerader Mannschaftszahl. Das Original + /// nimmt immer n − 1 (Befund #5). + /// + private int RasterSpieltage() => + Mannschaften.Count % 2 == 1 && Optionen.Korrekturen.VollstaendigesRaster ? Mannschaften.Count : Mannschaften.Count - 1; + /// Original CreateRasterOneRound. private void RasterEinerRunde(List mannschaften, List spieltagIndexe, RefRunde runde, bool rueckrunde, Zufall zufall) { List montage = SpieltagMontage(runde.Von, runde.Bis); - while (montage.Count > mannschaften.Count - 1) + while (montage.Count > RasterSpieltage()) { montage.RemoveAt(zufall.Zahl(montage.Count)); } diff --git a/src/AndiGenerator.Engine/Referenz/RefSpiel.cs b/src/AndiGenerator.Engine/Referenz/RefSpiel.cs index c71c185..4574678 100644 --- a/src/AndiGenerator.Engine/Referenz/RefSpiel.cs +++ b/src/AndiGenerator.Engine/Referenz/RefSpiel.cs @@ -60,10 +60,18 @@ public void SetzeDatum(double datum, Wunschterminoptionen optionen, int maxHeim, public void Merken() => gemerkt = (Datum, DatumOptionen, DatumMaxHeim, Wunschtermin, NichtNotwendig); /// Original TransferRefDateToDate für dieses Spiel (feste Spiele bleiben). - public void Zuruecksetzen() + /// + /// Befund #3: Den bisherigen Wunschtermin nur freigeben, wenn er noch diesem Spiel gehört (das Original gibt ihn immer frei). + /// + public void Zuruecksetzen(bool nurEigeneFreigeben) { if (!FestesDatum) { + if (nurEigeneFreigeben && Wunschtermin is not null && !ReferenceEquals(Wunschtermin.ZugeordnetesSpiel, this)) + { + Wunschtermin = null; + } + SetzeDatum(gemerkt.Datum, gemerkt.Optionen, gemerkt.MaxHeim, false, gemerkt.NichtNotwendig, gemerkt.Wunsch); } } diff --git a/src/AndiGenerator.Persistence/Optionen/OptionenDatei.cs b/src/AndiGenerator.Persistence/Optionen/OptionenDatei.cs index 216402d..f18b184 100644 --- a/src/AndiGenerator.Persistence/Optionen/OptionenDatei.cs +++ b/src/AndiGenerator.Persistence/Optionen/OptionenDatei.cs @@ -168,7 +168,17 @@ public static byte[] ErzeugenBytes(Berechnungsoptionen optionen) } } - text.Append("/>\r\n"); + text.Append("/>"); + + // Nur bei Abweichung vom Standard: So bleibt die Datei sonst byte-gleich zum Original, das das Element nicht kennt. + if (!optionen.SpielfreiAmPflichtspieltag) + { + text.Append(""); + } + + text.Append("\r\n"); return Iso885915.GetBytes(text.ToString()); } @@ -260,6 +270,12 @@ private static Berechnungsoptionen Auswerten(XElement wurzel) }; } + XElement? pflicht = wurzel.Elements("mandatory-games").FirstOrDefault(); + if (pflicht is not null) + { + o = o with { SpielfreiAmPflichtspieltag = AttributLesen(pflicht, "odd-team-free") != "false" }; + } + return o; } diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs index 7f5eb01..4796d48 100644 --- a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs @@ -29,6 +29,9 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable /// Dateiname der Anleitung neben dem Programm. public const string Anleitungsdatei = "Anleitung.pdf"; + /// Name des gemerkten Plans, unter dem nach einem unerwarteten Fehler der aktuelle Plan gesichert wird. + public const string NotfallsicherungName = "Notfallsicherung"; + private const string KeinExportplan = "Kein Plan zum Exportieren – erst eine Generierung starten oder einen Plan wählen."; private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); @@ -49,6 +52,9 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable private Plansitzung? sitzung; private Planstand? laufenderStand; + /// Abbruchfehler der Generierung, der schon gemeldet wurde (jeder wird nur einmal gemeldet). + private Exception? gemeldeterFehler; + /// Spiele des Plans, über dessen Sicherung schon entschieden ist (gesichert oder bewusst verworfen). private IReadOnlyList? sicherungEntschieden; private Planstand? clickTtStand; @@ -566,6 +572,31 @@ public void Dispose() /// false, wenn der Anwender abbricht und das Programm offen bleiben soll. public Task BeendenErlaubtAsync() => PlanSichernAsync("Das Programm wird beendet."); + /// + /// Sichert nach einem unerwarteten Fehler den aktuellen Plan als gemerkten Plan , ohne + /// zu fragen und ohne den gesicherten Plan anzutasten. Darf von jedem Thread aus aufgerufen werden; die Planauswahl wird + /// auf der Oberfläche nachgezogen. + /// + /// Der Name des gemerkten Plans oder null, wenn es keinen Plan gab oder er sich nicht schreiben ließ. + public string? NotfallSichern() + { + if (Volatile.Read(ref sitzung) is not Plansitzung s || laufenderStand is not Planstand aktuell) + { + return null; + } + + try + { + string name = s.PlanMerken(NotfallsicherungName, aktuell.Spiele).Name; + oberflaeche.AufOberflaeche(QuellenNeuAufbauen); + return name; + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + return null; + } + } + /// /// Legt die Vergrößerung fest, mit der die Ansichten starten (von der Oberfläche einmal nach der Größe des /// Bildschirms ermittelt, siehe ); gilt für die offenen und alle neuen Ansichten. @@ -2111,5 +2142,29 @@ private void StatusAbfragen() maschinenraumAnsicht?.Uebernehmen(stand); } }); + if (fehler is not null && !ReferenceEquals(fehler, Interlocked.Exchange(ref gemeldeterFehler, fehler))) + { + oberflaeche.AufOberflaeche(() => _ = AbbruchMeldenAsync(fehler)); + } + } + + /// + /// Ein Rechenthread der Generierung ist an einem unerwarteten Fehler gescheitert: protokollieren, den besten Plan als + /// Notfallsicherung ablegen und den Anwender einmal informieren. + /// + private async Task AbbruchMeldenAsync(Exception fehler) + { + string? protokoll; + try + { + protokoll = Fehlerprotokoll.Schreiben(eigeneBasis, fehler, "Generierung"); + } + catch (Exception schreibfehler) when (schreibfehler is IOException or UnauthorizedAccessException) + { + protokoll = null; + } + + string? gesichert = NotfallSichern(); + await oberflaeche.MeldenAsync("Generierung abgebrochen", Fehlerprotokoll.Meldung(fehler, gesichert, protokoll)); } } diff --git a/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs b/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs index 21f6e00..89f7477 100644 --- a/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs +++ b/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs @@ -20,6 +20,7 @@ public sealed class OptionenAnsichtViewModel : Document private readonly HauptfensterViewModel hauptfenster; private bool laden; private bool freitag; + private bool spielfreiAmPflichtspieltag; private int rundenplanung; private bool doppelrunde; private bool automatischeMitte; @@ -63,6 +64,22 @@ public bool FreitagZaehltZumWochenende } } + /// + /// Holt oder setzt einen Wert, der angibt, ob bei ungerader Mannschaftszahl am Pflichtspieltag eine Mannschaft spielfrei + /// sein darf, wenn sie in der Woche davor gespielt hat (Befund #33). + /// + public bool SpielfreiAmPflichtspieltag + { + get => spielfreiAmPflichtspieltag; + set + { + if (SetProperty(ref spielfreiAmPflichtspieltag, value)) + { + Aendern(o => o with { SpielfreiAmPflichtspieltag = value }); + } + } + } + /// Holt oder setzt die Rundenplanung als Index in . public int RundenplanungIndex { @@ -180,6 +197,7 @@ internal void Laden() try { FreitagZaehltZumWochenende = o.FreitagZaehltZumWochenende; + SpielfreiAmPflichtspieltag = o.SpielfreiAmPflichtspieltag; RundenplanungIndex = (int)o.Rundenplanung; Doppelrunde = o.Doppelrunde; AutomatischeMitte = o.AutomatischeMitte; diff --git a/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml index 7c83154..9b410ac 100644 --- a/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml +++ b/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml @@ -89,6 +89,9 @@ + diff --git a/src/AndiGenerator.UI/App.axaml.cs b/src/AndiGenerator.UI/App.axaml.cs index e7c9f70..bc47902 100644 --- a/src/AndiGenerator.UI/App.axaml.cs +++ b/src/AndiGenerator.UI/App.axaml.cs @@ -31,6 +31,7 @@ public override void OnFrameworkInitializationCompleted() var fenster = new HauptfensterView(); var oberflaeche = new AvaloniaOberflaeche(fenster); var modell = new HauptfensterViewModel(oberflaeche, () => new HostWindow()); + Fehlerbehandlung.Anmelden(modell, oberflaeche); fenster.DataContext = modell; desktop.MainWindow = fenster; diff --git a/src/AndiGenerator.UI/Fehlerbehandlung.cs b/src/AndiGenerator.UI/Fehlerbehandlung.cs new file mode 100644 index 0000000..c67a300 --- /dev/null +++ b/src/AndiGenerator.UI/Fehlerbehandlung.cs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Application; +using AndiGenerator.Presentation; +using Avalonia.Threading; + +namespace AndiGenerator.UI; + +/// +/// Fängt unerwartete Fehler ab, damit sie das Programm nicht still beenden: Jeder Fehler kommt ins Fehlerprotokoll im +/// eigenen Datenordner. Bei einem Fehler auf der Oberfläche wird zusätzlich der aktuelle Plan als „Notfallsicherung“ +/// gemerkt und einmal je Sitzung eine Meldung gezeigt; das Programm läuft weiter. Bei einem Fehler, der das Programm +/// ohnehin beendet (außerhalb der Oberfläche), wird der Plan noch gesichert und protokolliert. +/// +internal static class Fehlerbehandlung +{ + private static HauptfensterViewModel? modell; + private static IOberflaeche? oberflaeche; + private static int gemeldet; + + /// Meldet die Fehlerbehandlung für die ganze Anwendung an. + /// Das Hauptfenster (für die Notfallsicherung des Plans). + /// Dienste der Oberfläche (für die Meldung). + public static void Anmelden(HauptfensterViewModel hauptfenster, IOberflaeche dienste) + { + modell = hauptfenster; + oberflaeche = dienste; + Dispatcher.UIThread.UnhandledException += AufDerOberflaeche; + AppDomain.CurrentDomain.UnhandledException += Unbehandelt; + TaskScheduler.UnobservedTaskException += Unbeobachtet; + } + + private static async void AufDerOberflaeche(object? sender, DispatcherUnhandledExceptionEventArgs e) + { + e.Handled = true; + string text = Festhalten(e.Exception, "Oberfläche", sichern: true); + if (oberflaeche is not null && Interlocked.Exchange(ref gemeldet, 1) == 0) + { + await oberflaeche.MeldenAsync("Unerwarteter Fehler", text); + } + } + + private static void Unbehandelt(object? sender, UnhandledExceptionEventArgs e) => + Festhalten(e.ExceptionObject as Exception ?? new InvalidOperationException(e.ExceptionObject?.ToString()), "Programm", sichern: true); + + private static void Unbeobachtet(object? sender, UnobservedTaskExceptionEventArgs e) + { + Festhalten(e.Exception, "Hintergrundaufgabe", sichern: false); + e.SetObserved(); + } + + /// Protokolliert den Fehler, sichert auf Wunsch den Plan und liefert die Meldung für den Anwender. + private static string Festhalten(Exception fehler, string wobei, bool sichern) + { + string? protokoll; + try + { + protokoll = Fehlerprotokoll.Schreiben(Plansitzung.EigeneBasis(), fehler, wobei); + } + catch (Exception schreibfehler) when (schreibfehler is IOException or UnauthorizedAccessException) + { + protokoll = null; + } + + string? gesichert = sichern ? modell?.NotfallSichern() : null; + return Fehlerprotokoll.Meldung(fehler, gesichert, protokoll); + } +} diff --git a/tests/AndiGenerator.Persistence.Tests/FehlerprotokollTests.cs b/tests/AndiGenerator.Persistence.Tests/FehlerprotokollTests.cs new file mode 100644 index 0000000..7aba016 --- /dev/null +++ b/tests/AndiGenerator.Persistence.Tests/FehlerprotokollTests.cs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Application; + +namespace AndiGenerator.Persistence.Tests; + +/// Protokoll unerwarteter Fehler und die Meldung dazu. +public sealed class FehlerprotokollTests : IDisposable +{ + private readonly string basis = Path.Combine(Path.GetTempPath(), "andigen-fehler-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(basis)) + { + Directory.Delete(basis, recursive: true); + } + } + + [Fact] + public void Fehler_werden_angehaengt_und_ein_zu_grosses_Protokoll_wird_beiseitegelegt() + { + string pfad = Fehlerprotokoll.Schreiben(basis, new InvalidOperationException("erster Fehler"), "Oberfläche"); + Fehlerprotokoll.Schreiben(basis, new InvalidOperationException("zweiter Fehler"), "Generierung"); + string inhalt = File.ReadAllText(pfad); + Assert.Contains("· Oberfläche ·", inhalt, StringComparison.Ordinal); + Assert.Contains("erster Fehler", inhalt, StringComparison.Ordinal); + Assert.Contains("· Generierung ·", inhalt, StringComparison.Ordinal); + Assert.Contains("zweiter Fehler", inhalt, StringComparison.Ordinal); + + File.AppendAllText(pfad, new string('x', (int)Fehlerprotokoll.Hoechstgroesse)); + Fehlerprotokoll.Schreiben(basis, new InvalidOperationException("dritter Fehler"), "Programm"); + Assert.True(File.Exists(Path.Combine(basis, Fehlerprotokoll.AlterDateiname))); + string neu = File.ReadAllText(pfad); + Assert.Contains("dritter Fehler", neu, StringComparison.Ordinal); + Assert.DoesNotContain("erster Fehler", neu, StringComparison.Ordinal); + } + + [Fact] + public void Die_Meldung_nennt_Fehler_Notfallsicherung_und_Protokoll() + { + string meldung = Fehlerprotokoll.Meldung(new InvalidOperationException("kaputt"), "Notfallsicherung", @"C:\Daten\fehlerprotokoll.txt"); + Assert.Contains("kaputt", meldung, StringComparison.Ordinal); + Assert.Contains("„Notfallsicherung“", meldung, StringComparison.Ordinal); + Assert.Contains(@"C:\Daten\fehlerprotokoll.txt", meldung, StringComparison.Ordinal); + Assert.Contains(Fehlerprotokoll.Fehlermeldungsadresse, meldung, StringComparison.Ordinal); + + string knapp = Fehlerprotokoll.Meldung(new InvalidOperationException("kaputt"), null, null); + Assert.DoesNotContain("gesichert", knapp, StringComparison.Ordinal); + Assert.DoesNotContain("Einzelheiten", knapp, StringComparison.Ordinal); + } +} diff --git a/tests/AndiGenerator.Persistence.Tests/KorrekturenTests.cs b/tests/AndiGenerator.Persistence.Tests/KorrekturenTests.cs new file mode 100644 index 0000000..a0f812a --- /dev/null +++ b/tests/AndiGenerator.Persistence.Tests/KorrekturenTests.cs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Optionen; +using AndiGenerator.Domain.Stammdaten; +using AndiGenerator.Engine.Referenz; +using AndiGenerator.Persistence.Optionen; + +namespace AndiGenerator.Persistence.Tests; + +/// Korrekturen der Klasse-3-Befunde (MIGRATIONSPLAN E9, Entscheidung 11.10.2026) und die Regel für den Pflichtspieltag. +public sealed class KorrekturenTests +{ + [Fact] + public void Standard_korrigiert_alles_Original_nichts() + { + Assert.Equal(Korrekturen.Alle, Berechnungsoptionen.Standard.Korrekturen); + Assert.True(Berechnungsoptionen.Standard.SpielfreiAmPflichtspieltag); + Assert.Equal(Korrekturen.Keine, Berechnungsoptionen.Original.Korrekturen); + Assert.False(Berechnungsoptionen.Original.SpielfreiAmPflichtspieltag); + } + + [Theory] + [MemberData(nameof(ParitaetTests.Referenzfaelle), MemberType = typeof(ParitaetTests))] + public void Die_Regel_fuer_den_Pflichtspieltag_entlastet_hoechstens_eine_Mannschaft_je_Zeitraum(string fall) + { + Staffel staffel = ParitaetTests.StaffelLaden(fall); + int vorher = Pflichtverstoesse(Referenzbewertung.Bewerten(staffel, Berechnungsoptionen.Original)); + int nachher = Pflichtverstoesse(Referenzbewertung.Bewerten(staffel, Berechnungsoptionen.Original with { SpielfreiAmPflichtspieltag = true })); + if (staffel.Mannschaften.Count % 2 == 0) + { + Assert.Equal(vorher, nachher); + } + else + { + Assert.InRange(vorher - nachher, 0, staffel.Pflichtspielzeitraeume.Count(z => z.AnzahlSpiele == 1)); + } + } + + [Theory] + [MemberData(nameof(ParitaetTests.Referenzfaelle), MemberType = typeof(ParitaetTests))] + public void Summiert_wird_die_Ueberlappung_des_letzten_Spieltags_nie_kleiner(string fall) + { + Staffel staffel = ParitaetTests.StaffelLaden(fall); + Planbewertung original = Referenzbewertung.Bewerten(staffel, Berechnungsoptionen.Original); + Planbewertung summiert = Referenzbewertung.Bewerten( + staffel, + Berechnungsoptionen.Original with { Korrekturen = Korrekturen.Keine with { LetzteUeberlappungSummieren = true } }); + Assert.True(summiert.UeberlappungLetzterSpieltag >= original.UeberlappungLetzterSpieltag); + Assert.Equal(original.UeberlappungSpieltage, summiert.UeberlappungSpieltage); + } + + [Fact] + public void Die_Einstellung_wird_nur_bei_Abweichung_gespeichert() + { + byte[] standard = OptionenDatei.ErzeugenBytes(Berechnungsoptionen.Standard); + Assert.DoesNotContain("mandatory-games", System.Text.Encoding.Latin1.GetString(standard), StringComparison.Ordinal); + + string pfad = Path.Combine(Path.GetTempPath(), "andigen-korrektur-" + Guid.NewGuid().ToString("N") + ".options"); + try + { + OptionenDatei.Speichern(pfad, Berechnungsoptionen.Standard with { SpielfreiAmPflichtspieltag = false }); + Assert.False(OptionenDatei.Laden(pfad).SpielfreiAmPflichtspieltag); + OptionenDatei.Speichern(pfad, Berechnungsoptionen.Standard); + Assert.True(OptionenDatei.Laden(pfad).SpielfreiAmPflichtspieltag); + } + finally + { + File.Delete(pfad); + } + } + + private static int Pflichtverstoesse(Planbewertung bewertung) => + bewertung.Mannschaften.Sum(m => m.JeKostenart[(int)MannschaftsKostenart.Pflichtspieltage].Anzahl); +} diff --git a/tests/AndiGenerator.Persistence.Tests/ParitaetTests.cs b/tests/AndiGenerator.Persistence.Tests/ParitaetTests.cs index 297410f..ec31aa7 100644 --- a/tests/AndiGenerator.Persistence.Tests/ParitaetTests.cs +++ b/tests/AndiGenerator.Persistence.Tests/ParitaetTests.cs @@ -35,7 +35,7 @@ public void Kostentabelle_entspricht_dem_Original(string fall) { using JsonDocument json = JsonDocument.Parse(File.ReadAllText(Path.Combine(Referenzordner(), "werte", fall + ".json"))); JsonElement erwartet = json.RootElement; - Planbewertung bewertung = Referenzbewertung.Bewerten(StaffelLaden(fall), Berechnungsoptionen.Standard); + Planbewertung bewertung = Referenzbewertung.Bewerten(StaffelLaden(fall), Berechnungsoptionen.Original); var fehler = new List(); int vorher = verglichen; @@ -84,7 +84,7 @@ public void Kostentabelle_entspricht_dem_Original(string fall) Assert.True(verglichen - vorher >= 30, $"{fall}: nur {verglichen - vorher} Werte verglichen."); } - private static Staffel StaffelLaden(string fall) + internal static Staffel StaffelLaden(string fall) { string ordner = Path.Combine(Referenzordner(), "eingabe"); string xml = Directory.GetFiles(ordner, fall + "_*.xml").Single(); diff --git a/tests/AndiGenerator.Presentation.Tests/HauptfensterTests.cs b/tests/AndiGenerator.Presentation.Tests/HauptfensterTests.cs index 5b6cb9d..f80621d 100644 --- a/tests/AndiGenerator.Presentation.Tests/HauptfensterTests.cs +++ b/tests/AndiGenerator.Presentation.Tests/HauptfensterTests.cs @@ -666,6 +666,19 @@ public async Task Vor_einem_Neustart_wird_der_Plan_gesichert_und_danach_gefragt( hf.StoppenCommand.Execute(null); } + [Fact] + public async Task Nach_einem_unerwarteten_Fehler_wird_der_Plan_als_Notfallsicherung_gemerkt() + { + Assert.Null(hf.NotfallSichern()); + await OeffnenAsync(); + Assert.Null(hf.NotfallSichern()); + + await PlanErrechnenAsync(); + hf.StoppenCommand.Execute(null); + Assert.Equal(HauptfensterViewModel.NotfallsicherungName, hf.NotfallSichern()); + Assert.Single(Directory.EnumerateFiles(Path.Combine(ordner, "basis"), "*" + HauptfensterViewModel.NotfallsicherungName + "*", SearchOption.AllDirectories)); + } + [Fact] public async Task Generierung_Pause_Plan_merken_exportieren_und_entfernen() { diff --git a/tools/AndiGenerator.Cli/BewertenArgumente.cs b/tools/AndiGenerator.Cli/BewertenArgumente.cs index bc41e1a..1dfe6c9 100644 --- a/tools/AndiGenerator.Cli/BewertenArgumente.cs +++ b/tools/AndiGenerator.Cli/BewertenArgumente.cs @@ -21,6 +21,8 @@ internal sealed class BewertenArgumente public bool Details { get; private set; } + public bool Original { get; private set; } + public string? Ausgabe { get; private set; } /// Liest die Argumente nach dem Befehlswort; null bei Fehlern (Meldung in ). @@ -72,6 +74,12 @@ private bool Setzen(string arg, Queue werte, out string fehler) return true; } + if (arg == "--original") + { + Original = true; + return true; + } + if (arg == "--hilfe") { return false; diff --git a/tools/AndiGenerator.Cli/BewertenBefehl.cs b/tools/AndiGenerator.Cli/BewertenBefehl.cs index 5dfa25f..f53c66e 100644 --- a/tools/AndiGenerator.Cli/BewertenBefehl.cs +++ b/tools/AndiGenerator.Cli/BewertenBefehl.cs @@ -23,6 +23,7 @@ internal static class BewertenBefehl --optionen DATEI Optionsdatei (.options); "auto" = Staffelordner des Originals; Standard: Standardoptionen --rundenplanung R beide | halbrunde | vorrunde | rueckrunde (überschreibt die Optionsdatei) --details zusätzlich die Meldungen je Mannschaft ausgeben + --original Kosten wie im Original (ohne die Korrekturen und ohne die Regel für den Pflichtspieltag) --ausgabe DATEI Bildschirmausgabe zusätzlich in diese Datei schreiben Eine .modifications-Datei neben der XML-Datei wird automatisch berücksichtigt. """; @@ -69,6 +70,12 @@ private static int Bewerten(BewertenArgumente a) optionen = optionen with { Rundenplanung = r }; } + if (a.Original) + { + optionen = optionen with { Korrekturen = Korrekturen.Keine, SpielfreiAmPflichtspieltag = false }; + herkunft += ", wie im Original (ohne Korrekturen)"; + } + Console.WriteLine($"Staffel: {staffel.Name} ({staffel.Mannschaften.Count} Mannschaften)"); Console.WriteLine($"Optionen: {herkunft}, Rundenplanung {optionen.Rundenplanung}"); Console.WriteLine(); diff --git a/tools/AndiGenerator.Cli/OptimierenArgumente.cs b/tools/AndiGenerator.Cli/OptimierenArgumente.cs index 81d20c8..b31cf4a 100644 --- a/tools/AndiGenerator.Cli/OptimierenArgumente.cs +++ b/tools/AndiGenerator.Cli/OptimierenArgumente.cs @@ -21,6 +21,8 @@ internal sealed class OptimierenArgumente --rundenplanung R beide | halbrunde | vorrunde | rueckrunde (überschreibt die Optionsdatei) --leer mit leerem Plan beginnen (sonst mit dem bestehenden Spielplan der Datei) --ohne-spezial ohne Spezial-Insel + --original Suche und Kosten wie im Original (ohne die Korrekturen der Klasse-3-Befunde + und ohne die Regel für den Pflichtspieltag; zum Vergleich) --intervall N Statuszeile alle N Sekunden (Standard 10) --protokoll DATEI Verlauf als CSV (Sekunden;Durchläufe;Pläne/s;Kosten;Verbesserungen;Neustarts;Spezial) --plan DATEI besten Plan als gemerkten Plan (Format des Originals) speichern @@ -48,6 +50,9 @@ Eine .modifications-Datei neben der XML-Datei wird automatisch berücksichtigt. public bool OhneSpezial { get; private set; } + /// Holt einen Wert, der angibt, ob ohne Korrekturen gerechnet wird (wie das Original). + public bool Original { get; private set; } + public int Intervall { get; private set; } = 10; public string? Protokoll { get; private set; } @@ -121,6 +126,9 @@ private bool Setzen(string arg, Queue werte, out string fehler) case "--ohne-spezial": OhneSpezial = true; return true; + case "--original": + Original = true; + return true; case "--sekunden" when Zahl(werte, arg, out zahl, out fehler): Sekunden = (int)Math.Min(zahl, int.MaxValue); return true; diff --git a/tools/AndiGenerator.Cli/OptimierenBefehl.cs b/tools/AndiGenerator.Cli/OptimierenBefehl.cs index b88f3d3..20a41c2 100644 --- a/tools/AndiGenerator.Cli/OptimierenBefehl.cs +++ b/tools/AndiGenerator.Cli/OptimierenBefehl.cs @@ -84,6 +84,12 @@ private static async Task Optimieren(OptimierenArgumente a) { Staffel staffel = StaffelLaden(a.Datei, a.Leer); (Berechnungsoptionen optionen, string herkunft) = OptionenLaden(a.Optionen, staffel); + if (a.Original) + { + optionen = optionen with { Korrekturen = Korrekturen.Keine, SpielfreiAmPflichtspieltag = false }; + herkunft += ", wie im Original (ohne Korrekturen)"; + } + if (a.Rundenplanung is Rundenplanung runde) { optionen = optionen with { Rundenplanung = runde };