Entwicklung: Bilder und Prüfungen der Karten¶
Die vollständige Entwickler-Dokumentation ist auf Englisch: Development. Diese Seite beschreibt auf Deutsch, wie die Bilder der Dokumentation entstehen und wie sie aktuell bleiben, wie die Karten bei jeder Änderung auf ihr Aussehen und ihre Barrierefreiheit geprüft werden, wie der Browsertest dabei wartet und in vier Teilen nebeneinander läuft, wie ihre Tests die Timer der Karten prüfen, wann die End-to-End-Tests laufen, wie sich ein Check dieses Projekts erklärt und selbst repariert, wie beide Doubles des Board Managers wie seine aufgezeichneten Antworten antworten, welche Hosts die Jobs erreichen, wie sich ein Board und seine Spiele den Code teilen, wie die Karten ihre Texte bekommen und wie die Mutationstests zeigen, was die Tests nicht prüfen.
Auf dieser Seite: Bilder der Dokumentation · In jedem Lauf dieselben Bilder · Gruppen nebeneinander · Screenshot-Bot · Bildvergleich · Wie der Browsertest wartet · Der Browsertest in Teilen · Prüfung der Barrierefreiheit · Timer der Karten · Wann die End-to-End-Tests laufen · Wenn ein Check dieses Projekts fehlschlägt · Aufgezeichnete Antworten des Board Managers · Netzwerk der Jobs · Das Board und seine Spiele · Texte der Karten · Mutationstests
Bilder der Dokumentation¶
tests/e2e/screenshots.sh erzeugt jedes Bild in docs/images/en und docs/images/de neu aus der Demo, auch die animierten WebP-Bilder, und komprimiert die PNG-Bilder mit pngquant. tests/e2e/screenshots.py hat eine Funktion pro Bild oder Animation, sodass sich ein einzelnes Bild neu erzeugen lässt, indem man seine Funktion gegen eine laufende Demo aufruft. Jedes Bild zeigt das simulierte Board und erfundene Spieler, persönliche Daten können also nicht erscheinen; die geheime Adresse der Online-Brücke wird abgedeckt. Die Suche im Netzwerk öffnet das Werkzeug nie, sie würde echte Boards zeigen. Die Diagramme in docs/images entstehen dagegen mit scripts/render_diagrams.sh.
Schlägt eine Aufnahme fehl, speichert das Werkzeug jede offene Seite in tests/e2e/artifacts/. Git ignoriert diesen Ordner.
In jedem Lauf dieselben Bilder¶
Zwei Läufe des Werkzeugs erzeugen dieselben Bilder, Byte für Byte. Ein Bild, das sich ändert, zeigt also immer eine Karte oder eine Seite, die sich geändert hat, und Screenshot-Bot und Bildvergleich erkennen das:
- Eine eingefrorene Uhr. Die Demo der Screenshots steht auf Dienstag, dem 29. September 2026, 20:30 Uhr (
DEMO_TIMEinscreenshots.sh), ganz gleich, wann sie läuft.tests/e2e/frozen/sitecustomize.pystellt diese Uhr für Home Assistant und jedes Skript in seinem Container, übercompose.frozen.yaml, dasdemo.shhinzunimmt, wennDEMO_TIMEgesetzt ist. Die Uhr geht nur weiter, wenn die Demo sie weiterstellt: Jede Bewegung am simulierten Board, ein Dart, das Herausziehen oder ein neuer Status, dauert zwei Sekunden, indemo.pywie inscreenshots.py, geschrieben in die Datei.demo_clockin der Konfiguration der Demo. So kommt jede Zeit, die Home Assistant festhält, in jedem Lauf gleich heraus, auf die Millisekunde, während der Verlauf des Boards, die Länge einer Session und die Trainingszeit des Wochenberichts aussehen wie an einem echten Abend; der Wochenbericht bekommt die Minute, auf die er wartet, auf dieselbe Weise. Auch der Browser zeigt die Zeit der Demo: Die Uhr von Playwright steht auf ihr still, außer in den Seiten der Spiel-Animationen, in denen das Ergebnis eines Turnierspiels seine Zeit lang stehen bleiben muss; ihre Bilder zeigen keine Uhrzeit. Die Seite der Statistik-Grafiken wünscht reduzierte Bewegung, damit Home Assistant ihre Linien sofort zeichnet statt als Animation, die die stehende Uhr anhalten würde. - Eine Quelle für Änderungen. Die eingefrorene Demo fragt das Board nicht regelmäßig ab: Jede Änderung kommt über die Verbindung zum Board, in der Reihenfolge, in der das Board sie sendet, nie zuerst aus einer Abfrage, deren Zeitpunkt vom Lauf abhängt.
- Feste Zufallszahlen. Die Darts des Bots und jede andere Zufallszahl ohne eigenen Startwert beginnen mit demselben Startwert.
- Ruhige Bilder. Vor jedem Screenshot und jedem Bild einer Animation wartet das Werkzeug auf Bilder, die noch laden, lässt Übergänge und Einblendungen an ihr Ende springen und hält endlose Animationen wie die blinkenden Felder im selben Moment an. Das wiederholt es, bis sich keine Karte mehr ändert und zwei Blicke im Abstand einer Zehntelsekunde nichts finden, das sich bewegt. Davor wartet eine Seite, die sich öffnet oder ihre Größe ändert, bis sie hat, was Home Assistant ihr schickt: jede Karte gezeichnet, ihre Schriften geladen und keine Anfrage des Frontends am Websocket ohne Antwort, mit
loaded()auspages.pydes Browsertests, statt einer festen Zeit, die auf einem langsamen Runner zu kurz ist. Chromium zeichnet jedes Bild ganz und animiert auf seinem Hauptthread (STEADY_CHROMIUMinscreenshots.py). Die Werkzeugleiste eines Dashboards wirft keinen Schatten auf das, was unter ihr scrollt, eine Karte, die höher als der Bildschirm ist, wird vom Anfang der Seite aufgenommen, und der Dialog der Board-Ereignisse öffnet sich erst, wenn der Rekorder das letzte Ereignis geschrieben hat, das er zeigt (RECORDED), nicht nach einer festen Zeit. - Farben in fester Reihenfolge. Home Assistant gibt jedem Zustand, den es in einem Verlauf oder einer Aktivitätsliste zeichnet, eine eigene Farbe, die nächste seiner Palette, in der Reihenfolge, in der es die Zustände zuerst zeichnet, solange die Seite besteht; die Ereignis-Entität des Boards bekommt mit jedem Ereignis einen neuen Zustand. Die Zeitleiste eines Verlaufs zeichnet in jedem Pixel nur den Zustand, der dort am längsten dauert. Welche Zustände sie zuerst färbt, hängt also von ihrer Breite in dem Moment ab, in dem ihre Daten ankommen, und Verlauf und Aktivität des Dialogs kommen in beliebiger Reihenfolge an. Deshalb zeichnet das Werkzeug, bevor sich der Dialog öffnet, jedes Ereignis der letzten 24 Stunden einmal, das neueste zuerst, in einer eigenen, unsichtbaren Aktivitätsliste (
COLOUR_EVENTSinscreenshots.py): Der Dialog findet jedes Ereignis gefärbt vor, und die neuesten bekommen immer die ersten Farben der Palette, ganz gleich, wie viele Ereignisse davor kamen. - Alle Texte der Einstellungen. Home Assistant lädt die Texte seiner Einstellungen, etwa die Namen ihrer Tabs, getrennt von seinen übrigen Texten. Es führt jede Ladung mit den Texten zusammen, die es schon hat, zeigt aber nur, was seine neueste Ladung zusammengestellt hat, und verlässt sich darauf, dass seine Timer ohne Verzögerung in der Reihenfolge laufen, in der sie angefordert wurden. Das müssen sie nicht: Chrome hält einen Timer zurück, der tief in anderen Timern angefordert wird, und die stehende Uhr von Playwright einen, der während eines Ticks angefordert wird. Überholt eine spätere Ladung die der Einstellungen, fehlen einer Seite der Einstellungen die Namen ihrer Tabs und die Köpfe ihrer Spalten, solange die Seite besteht; so sind zwei Läufe des Screenshot-Bots gescheitert. Deshalb warten die Seiten der Einstellungen, bis ihre Texte zusammengeführt sind, und lassen Home Assistant seine Texte dann noch einmal aktualisieren (
SETTINGS_TEXTSinscreenshots.py). - Feste Adressen. Die Online-Brücke zeigt
homeassistant.localals Adresse von Home Assistant (internal_urlindemo.configuration.yaml), nicht die Adresse, die Docker dem Container zufällig gibt. - Eine feste Version. Hinter den Optionen der Online-Brücke zeigt die Seite der Integration ihre Version, und jedes Release ändert sie. In der eingefrorenen Demo lässt
sitecustomize.pyHome Assistant nur die Hauptversion aus dem Manifest lesen: Die Seite zeigt Version 1 für jedes Release 1.x. So ändert ein Release kein Bild, und der Bildvergleich meldet das Bild danach nicht bei jedem Pull Request. Alles andere auf der Seite zeigt Home Assistant so, wie es ist; außerhalb der eingefrorenen Demo zeigt es die volle Version. - Festgelegte Kompression. pngquant 2.18.0 aus Ubuntu 24.04 komprimiert jedes PNG-Bild direkt nach der Aufnahme.
tests/e2e/pngquant.shinstalliert es im festgelegten Playwright-Image von Launchpad, wo jedes veröffentlichte Paket seine Adresse behält, und prüft beide Pakete gegen ihre SHA-256-Prüfsumme. Die Animationen entstehen mit dem festgelegten Pillow.
Um herauszufinden, was sich zwischen zwei Läufen unterscheidet, behält KEEP_RAW=1 jedes Bild und jedes Bild einer Animation so, wie es aufgenommen wurde, vor der Kompression, in tests/e2e/artifacts/raw/, einem Ordner pro Sprache; tests/e2e/compare.py vergleicht zwei Ordner mit Bildern.
Ohne DEMO_TIME, wie bei demo.sh für eine Vorschau oder bei browser.sh, läuft die Demo mit der echten Uhr.
Gruppen nebeneinander¶
Das Werkzeug erzeugt seine Bilder in drei festen Gruppen (GROUPS in screenshots.py), und jede Gruppe beginnt mit einer frischen Demo: die Karten mit den ersten Spielen; weitere Spiele mit einem Turnier und danach die Spieler; die Korrekturen eines Darts und ein Spiel gegen den Bot, die Formate, der Kalender und die Dartscheibe ohne Autodarts. Die Abschnitte einer Gruppe laufen in ihrer Reihenfolge, und ein späterer sieht, was ein früherer gespielt hat: Die Spielerkarte zeigt die fünf Spieler des Turniers davor. Eine Gruppe sieht nie, was eine andere gespielt hat. Sie erzeugt also allein dieselben Bilder wie nach den anderen, und die Gruppen können nebeneinander laufen, jede auf einem eigenen Runner:
screenshots.sh und visual.sh erzeugen jede Gruppe nacheinander, jede aus einer frischen Demo, und SCREENSHOT_GROUP=2/3 erzeugt nur die zweite Gruppe. IMAGES nennt den Ordner unterhalb des Repositorys, in den die Bilder kommen, ohne Angabe docs/images. Screenshot-Bot und Bildvergleich erzeugen jede Gruppe jeder Sprache in einem eigenen Job, sechs Jobs nebeneinander mit je etwa acht Minuten, wo vorher zwei Jobs zwanzig Minuten brauchten. Ein Job je Sprache setzt danach seine Gruppen zusammen und vergleicht sie mit den eingecheckten Bildern (tests/e2e/collect.sh), wie visual.sh es auf einem Rechner tut; er schlägt fehl, wenn eine Gruppe keine Bilder hinterlassen hat oder zwei Gruppen dasselbe Bild erzeugt haben.
Jeder Job einer Gruppe nennt in seiner Zusammenfassung die Sekunden seiner Abschnitte, und das Protokoll zeigt sie als Screenshot section: people (186.2 s). Wird eine Gruppe deutlich länger als die anderen, wandert ein Abschnitt in eine andere Gruppe, solange die Abschnitte, die voneinander abhängen, in ihrer Reihenfolge zusammenbleiben: die Karten vor den Spielen, deren Legs die Grafiken der Woche beenden würden; das Turnier vor den Spielern; das erste Spiel des Bots vor den Formaten, die wieder gegen den Bot spielen; der Kalender und der Wochenbericht, der eine neue Woche beginnt, und danach die Dartscheibe ohne Autodarts, deren zweites Board das erste der Karten ohne eines wäre, als Letzte ihrer Gruppe. Ein Umzug ändert einmal die Bilder, deren Daten aus den Abschnitten davor kamen, und der Screenshot-Bot bringt sie nach dem Merge nach docs/images. tests/test_screenshot_groups.py prüft, dass jeder Abschnitt zu genau einer Gruppe gehört, dass diese Reihenfolgen gelten und dass die Skripte und beide Workflows so viele Gruppen erzeugen, wie GROUPS hat.
Screenshot-Bot¶
Der Workflow Screenshot bot (.github/workflows/screenshots.yml) hält die Bilder aktuell. Wenn eine Änderung der Karten (custom_components/homedarts/frontend/) oder des Screenshot-Werkzeugs (tests/e2e/, außer Browsertest und End-to-End-Test, und die aufgezeichneten Antworten seines simulierten Board Managers) auf main landet, jeden Donnerstag und auf Abruf erzeugt er jedes Bild beider Sprachen neu, in Gruppen nebeneinander, und vergleicht es Pixel für Pixel mit dem eingecheckten. Ein Bild, dessen Pixel gleich geblieben sind, behält seine Datei. Nur die erzeugten Bilder kommen über die von main: Die Diagramme in docs/images, die main bis zum Ende des Erzeugens neu haben kann, bleiben, wie sie sind, und ein Bild, das weder das Werkzeug noch ein Diagramm in docs/diagrams mehr erzeugt, fällt weg.
Auch ein Release startet den Bot. Es schreibt seine Version in custom_components/homedarts/manifest.json, und die Integrationsseite in online-bridge.png zeigt davon die Hauptversion (eine feste Version): Ein Release derselben Hauptversion erzeugt jedes Bild wie zuvor, eine neue Hauptversion wie 2.0.0 ändert dieses Bild. Den Release-Pull-Request merget der Maintainer oder die Release-App, also startet sein Push auf main Workflows wie jeder andere; nur ein Merge mit dem GITHUB_TOKEN eines Workflows startete keine. So folgt das Bild einer neuen Hauptversion binnen einer Stunde statt erst am Donnerstag, und der Bildvergleich späterer Pull Requests meldet es nicht tagelang als geändert.
Sein Job timeline zeichnet zugleich die Spiele der Live-Demo neu auf, und der Pull Request bringt eine geänderte Aufzeichnung mit den Bildern.
Hat sich ein Bild geändert, öffnet der Bot einen Pull Request, docs: update the screenshots of the cards, oder aktualisiert den noch offenen, und schaltet Auto-Merge ein: Die Bilder folgen, sobald jede Prüfung bestanden ist. Die Beschreibung nennt jedes geänderte Bild mit dem Anteil seiner Pixel, der sich geändert hat; der Workflow-Lauf behält die Bilder vorher und nachher mit ihren Unterschieden drei Tage lang. Der Pull Request kommt von der Release-App, damit seine Prüfungen laufen, und seine Commits gehen über die API von GitHub: GitHub signiert sie, wie main es verlangt, und sie tragen das Signed-off-by: der App. Der Branch des Pull Requests beginnt bei main, wie es nach dem Erzeugen der Bilder steht, nicht beim Commit, aus dem sie erzeugt wurden: Ein Branch, dessen Workflows von denen auf main abweichen, gilt GitHub als Änderung der Workflows, und dafür hat die App keine Berechtigung. Die Bilder kommen zuerst auf einen eigenen Branch, und der Branch des Pull Requests springt in einem Schritt dorthin: Zeigte er auch nur kurz auf einen Commit, den main schon hat, schlösse das seinen Pull Request. Lässt sich der Branch eines offenen Pull Requests wegen der Workflows, die sich seitdem auf main geändert haben, nicht verschieben, beginnt er neu, und ein neuer Pull Request löst den alten ab. Hat main die Bilder eines noch offenen Pull Requests schon, schließt der Bot ihn. Eine Gruppe, die sich nicht erzeugen lässt, hält nur ihre Sprache auf: Der Pull Request übernimmt die Bilder der Sprache, die erzeugt wurde, und behält für die andere die erzeugten Bilder, die er hatte, aus dem Branch der App in diesem Repository oder wie auf main: Ein Diagramm kommt von main, und der Pull Request eines Forks mit einem Branch gleichen Namens gibt ihm nichts; solange eine Sprache fehlt, schließt der Bot keinen Pull Request. Wurde keine Sprache erzeugt, schlägt der Job fehl, und der Pull Request bleibt, wie er ist. Das Erzeugen der Bilder läuft ohne jedes Geheimnis; nur der letzte Job, der den Pull Request schreibt, nutzt den Schlüssel der Release-App aus der Umgebung release.
Live-Demo der Website¶
Die Live-Demo spielt die Karten auf der Website, ohne Home Assistant: ein 501 gegen den Bot, ein Cricket-Spiel und ein Turnier, in den fünf Sprachen der Karten, auf einem Desktop und einem Handy. Niemand zeichnet sie von Hand auf, und nichts darin ist erfunden.
scripts/record_demo.pylässt die Integration die Spiele im Home Assistant der Tests spielen, an einem simulierten Board, mit einer Uhr, die nur mit den Spielen weitergeht (tests/demo_timeline.pybeschreibt sie). Nach jedem Zug hält es die Zustände der Entitäten, die sich geändert haben, und die Antworten auf den Positionsbefehl der Trainingskarte indocs/demo/timeline.jsonfest, eine Zeile je Schritt. Zwei Aufzeichnungen desselben Codes sind Byte für Byte gleich.tests/test_demo_timeline.pyspielt die Spiele in jedem Lauf der Tests, also schlägt dort fehl, was sie kaputt macht.docs/demo/index.htmlunddocs/demo/demo.jssind die Seite. Ein kleiner Ersatz für Home Assistant gibt den Karten die Zustände jedes Schritts, wie Home Assistant es täte, mit jedem Datum und jeder Uhrzeit darin auf den Moment der Wiedergabe verschoben, damit ein Countdown zählt und „seit“ heute zeigt. Eine Schaltfläche einer Karte oder ein Tipp auf eine Entität zeigt nur einen Hinweis, dass das eine Aufzeichnung ist; nichts verlässt die Seite. Die Website nimmt die Karten unverändert aus der Integration (on_post_buildinscripts/mkdocs_hooks.py), also zeigt die Demo immer die Karten ihrer Version.- Der Screenshot-Bot zeichnet die Spiele mit den Bildern neu auf, in seinem Job timeline, und sein Pull Request bringt die neue Aufzeichnung auf
main. So folgt die Demo der Integration und den Karten wie die Bilder. tests/frontend/demo.test.mjstestet die Seite mit den Karten in happy-dom, und der Job live demo der End-to-End-Tests spielt sie in Chromium (bash tests/e2e/live_demo.sh): in jeder Sprache, auf einem Desktop und einem Handy, jeden Schritt, ohne Fehler in der Konsole und ohne Seite, die breiter als der Bildschirm ist.
Zum Neuaufzeichnen und Ansehen, mit den Paketen der Tests und der Website:
python scripts/record_demo.py
mkdocs serve
Dann öffnest du http://127.0.0.1:8000/demo/index.html?lang=de.
Bildvergleich¶
Der Workflow Visual check (.github/workflows/visual.yml) erzeugt die Bilder auf dieselbe Weise für jeden Pull Request, der die Karten, das Screenshot-Werkzeug oder die aufgezeichneten Antworten seines simulierten Board Managers ändert, und vergleicht sie mit den eingecheckten. Er meldet nur, denn eine gewollte Änderung kommt nach dem Merge über den Screenshot-Bot nach docs/images:
- Die Zusammenfassung des Jobs compare jeder Sprache nennt jedes Bild, das anders aussieht, mit dem Anteil seiner Pixel, der sich geändert hat.
- Die Artefakte
visual-enundvisual-debehalten jedes geänderte Bild vorher und nachher und ein Bild des Unterschieds: das neue Bild in Grau, jedes geänderte Pixel in Rot. - Der Workflow Visual check comment (
visual-comment.yml) schreibt die Liste in einen Kommentar am Pull Request, mit Links zu den Artefakten, und aktualisiert ihn nach jedem Lauf.
Ein Bild, das sich nicht erzeugen lässt, lässt die Prüfung fehlschlagen, und die andere Sprache bekommt trotzdem ihren Bericht: die Zusammenfassung ihres Jobs compare, ihr Artefakt und den Kommentar, der ihre Bilder auflistet und die Sprache nennt, die sich nicht erzeugen ließ. Der Kommentar-Workflow startet, wenn eine Prüfung endet (workflow_run), damit er auch Pull Requests aus Forks kommentieren darf, checkt ihren Code aber nie aus und führt ihn nie aus: Er liest nur die Berichte der Prüfung, übernimmt daraus nichts als Bildnamen und Zahlen, die er prüft, und schreibt den Kommentar selbst. Die Prüfung läuft nur, wenn sich ihre Dateien ändern, und ist deshalb keine Pflichtprüfung.
Derselbe Vergleich läuft auch lokal; er lässt die neuen Bilder in docs/images und den Bericht in tests/e2e/artifacts/visual/. Jede Gruppe einer Sprache braucht etwa sechs Minuten, und eine Gruppe allein erzeugt nur ihre eigenen Bilder:
LANGUAGES=de bash tests/e2e/visual.sh
SCREENSHOT_GROUP=2/3 LANGUAGES=de bash tests/e2e/visual.sh # nur die zweite Gruppe
git checkout -- docs/images # zurück zu den eingecheckten Bildern
Wie der Browsertest wartet¶
Der Browsertest (tests/e2e/browser.py) wartet auf das, was er prüft, nie eine feste Zeit, die auf einem langsamen Runner zu kurz und überall sonst zu lang wäre. tests/e2e/pages.py enthält seine Wartefunktionen:
show()öffnet eine Ansicht auf mehreren Seiten zugleich: beim ersten Mal mit einem Laden der Seite, danach in der App, wie es ihre Links tun. So bleiben das Frontend und sein Websocket erhalten, und eine Ansicht braucht eine Zehntelsekunde statt eines Ladens der ganzen App. Eine Adresse mit einer Abfrage oder einem Fragment, etwa?edit=1für den Bearbeitungsmodus, lädt die Seite, und ebenso jede Ansicht danach: Die App liest sie nur beim Start und behält, was sie setzen, in den Ansichten, die sie danach in sich öffnet. Es wartet, bis die Karten der Ansicht die der Ansicht davor ersetzt und sich gezeichnet haben.expect_state()wartet, bis jede Seite die Zustände zeigt, die ein Zug am Board setzt, etwanum_throws2 nach dem zweiten Dart einer Aufnahme oderlocal_statustakeout_in_progress, während die Darts herausgezogen werden. Einen Zustand, den das Frontend noch nicht kennt, etwa während es sich noch verbindet, zählt sie als noch nicht da und wartet weiter.settle()wartet, bis eine Seite zur Ruhe gekommen ist: Ihre Karten haben sich gezeichnet, keine Anfrage des Frontends am Websocket von Home Assistant wartet auf ihre Antwort, 100 ms kam kein Frame, keine Animation einer Karte läuft, und der Browser hat seitdem zwei Frames gezeichnet. Ein Skript, das jede Seite vor dem von Home Assistant ausführt, verfolgt dafür den Websocket.run_until()lässt die Zeit eines Timers einer Karte in einem Augenblick vergehen, statt sie abzuwarten. Die Seiten von tournament und tablet screen laufen mit der Uhr von Playwright, die der Schritt einsetzt, bevor die Seite lädt (page.clock.install()), undrun_until()stellt diese Uhr sekundenweise vor, mit jedem Timer, der dabei fällig wird, bis sich zeigt, wozu der Timer führt: die Tabelle mit dem nächsten Spiel nach den acht Sekunden der Zusammenfassung eines Spiels und der Ruhemodus, sobald die Anzeigetafel der Ansicht für den Ruhemodus zehn Sekunden lang ruhig war. Ein Ziel, das sich nicht in der Zeit zeigt, die ihm der Schritt auf der Uhr der Seite gibt, lässt den Schritt mit dem fehlschlagen, was die Karte stattdessen zeigte. So schlägt er weiter fehl, wenn eine Zusammenfassung nicht endet oder der Ruhemodus nicht beginnt, und die beiden Schritte brauchen ein paar Sekunden statt einer halben Minute.
Die Schritte, die viele Bildschirme ansehen, öffnen sie nebeneinander und spielen jedes Spiel einmal für alle: every screen size seine neun Bildschirme und danach die Telefone in vier weiteren Sprachen, steady heights seine vier Bildschirme mit je drei Ansichten, accessibility seine vier Bildschirme und Designs, auf denen axe-core zugleich prüft, und a dartboard without Autodarts seine neun Bildschirme. Eine Wartezeit, die abläuft, nennt die Seite mit der Größe ihres Bildschirms und ihrer Sprache, worauf sie wartete und was die Seite stattdessen zeigte, etwa die Anfrage ohne Antwort oder den Zustand jeder Entität.
tests/test_e2e_waits.py schlägt fehl, wenn der Browsertest mit page.wait_for_timeout eine feste Zeit wartet, außer bei den zwei Wartezeiten, die er mit ihren Gründen nennt, jede einmal aufgerufen: ein Finger, der 120 ms auf der Lupe ruht, bevor er loslässt, und die sechs Sekunden, die der Safari-Schritt homedarts-card.js zurückhält. Kein Skript, das einen Browser steuert, darf time.sleep aufrufen: Die synchrone API von Playwright verarbeitet die Ereignisse des Browsers, etwa eine Anfrage, die eine Route zurückhält, nur in ihren eigenen Aufrufen, also hielte ein Schlaf sie alle an. tests/test_e2e_waits.py schlägt auch fehl, wenn ein Schritt die Uhr einer Seite vorstellt, die er erst einsetzt, nachdem die Seite geladen hat, oder nie, denn dann liefen die Timer der Karten auf der echten Uhr.
Nach jedem Schritt zeigt das Protokoll seine Sekunden, etwa Browser step: steady heights (74.2 s), und browser-steps-table.md bei den Artefakten führt jeden Schritt mit seinen Sekunden auf; in der CI zeigt die Zusammenfassung des Jobs diese Tabelle.
Der Browsertest in Teilen¶
Nacheinander brauchen die Schritte fünf Minuten, zu lang für einen Job. Jeder Schritt gehört zu einer von vier Gruppen mit etwa gleich vielen Sekunden (steps() in tests/e2e/browser.py), und BROWSER_SHARD=k/n führt die Gruppen g mit (g - 1) % n = k - 1 aus: 1/1, die Vorgabe, jede Gruppe, 1/2 die Gruppen 1 und 3, 2/4 die Gruppe 2. BROWSER_WORKERS=W teilt den Teil des Rechners noch einmal: Worker w führt den Teil (k - 1) * W + w von n * W auf einer eigenen Demo-Instanz aus, alle nebeneinander, sodass 1/2 allein die Gruppen 1 und 3 ausführt, 1/2 mit zwei Workern aber die Teile 1/4 und 2/4, die Gruppen 1 und 2. Jeder Worker hat seinen Browser, einem eigenen Compose-Projekt (homedarts_browser_1, …), dem nächsten Port ab 18125 und einem eigenen Ordner bei den Artefakten (shard-3-of-4). Jede Zeile eines Workers im Protokoll beginnt mit seinem Teil, etwa [3/4]. Mehr Teile als Gruppen lehnt browser.sh ab, bevor es irgendetwas startet, etwa BROWSER_SHARD=2/4 mit BROWSER_WORKERS=2.
BROWSER_WORKERS=4 bash tests/e2e/browser.sh # alle Schritte, vier Teile zugleich
BROWSER_SHARD=2/4 bash tests/e2e/browser.sh # ein Teil: die gleichbleibenden Höhen
BROWSER_SHARD=1/2 BROWSER_WORKERS=2 bash tests/e2e/browser.sh # der erste Runner der CI: Gruppen 1 und 2
BROWSER_STEPS führt nur die genannten Schritte aus, durch Kommas getrennt: Der ganze Name eines Schritts führt diesen allein aus, etwa BROWSER_STEPS="training card" ohne den training card editor, der Anfang von Namen jeden Schritt, mit dem sie beginnen, etwa BROWSER_STEPS="touch".
Jeder Schritt beginnt mit dem Board zwischen zwei Spielen, was auch immer der Schritt davor hinterließ: Board.between_games() in browser.py beendet ein Turnier, schaltet das Tastenfeld, das Spiel und die Teams aus, stellt einen Spieler, ein Leg und einen Satz ohne den Bot ein, startet die Erkennung, räumt das Board ab und wartet, bis Home Assistant das alles zeigt, über eine eigene Seite ohne Karte. Eine Karte, die ein Spiel zeigte, öffnet acht Sekunden nach seinem Ende von selbst die Spielauswahl; ein weiterlaufendes Spiel würde sie also mitten im nächsten Schritt öffnen. Nur die Trainingskarte und die Live-Karte lesen die Demo so, wie demo.py sie hinterließ, ihre Sitzung und die Aufnahme auf dem Board; sie kommen in Gruppe 1 zuerst, und vor ihnen wird nichts zurückgesetzt. So besteht jeder Teil und jeder Schritt für sich, wie der ganze Lauf.
tests/test_e2e_shards.py prüft die Gruppen ohne Browser: Jeder Schritt hat eine, jede Zahl von Teilen von eins bis vier führt jeden Schritt genau einmal und in seiner Reihenfolge aus, die Schritte, die die Demo wie zu Beginn lesen, kommen zuerst, und die Dartscheibe ohne Autodarts zuletzt. Außerdem führt er browser.sh mit einem Ersatz für docker aus, der zeigt, welchen Teil, welches Projekt und welche Artefakte jeder Worker bekommt, und dass ein fehlgeschlagener Worker den Lauf fehlschlagen lässt und als einziger seine Protokolle behält. Wächst ein Schritt, zeigt browser-steps-table.md seines Teils das, und ein Schritt, der in steps() in eine andere Gruppe wandert, gleicht die Teile wieder aus.
Prüfung der Barrierefreiheit¶
Der Schritt accessibility des Browsertests (tests/e2e/browser.py) lässt axe-core über jede Karte jeder Ansicht beider Dashboards laufen, über den Ruhebildschirm, ein Übungsspiel auf der Live-Karte und die eigenen Seiten der Anzeigetafel: die Spielauswahl, das Tastenfeld mit seiner Scheibe zum Antippen, die Scheibe einer Korrektur und Cricket. Das geschieht auf einem Laptop (1280 × 800) und einem iPhone (393 × 852) mit Touch, jeweils im dunklen und im hellen Design, nach den Regeln von WCAG 2.1 in den Stufen A und AA, etwa zum Kontrast von Text, zu Namen von Bedienelementen und Rollen und zum Erreichen dessen, was scrollt, mit der Tastatur.
- Ein schwerer oder kritischer Verstoß lässt den Schritt fehlschlagen, mit Bildschirm, Ansicht, Karte, Regel, Element und dem, was nicht stimmt. Mittlere und leichte nennt das Protokoll.
tests/e2e/accessibility-baseline.tomlführt die Verstöße, die vorerst hingenommen werden, jeden mit seinem Grund: Ein Eintrag nennt die Regel, die Karte und eintarget, das Teil des Selektors ist, den axe-core dem Element gibt. Der Schritt nennt einen Eintrag, der nicht mehr vorkommt, damit er geht. Die Liste ist leer: Jede Karte besteht.- Der Schritt behält jeden Verstoß und jede geprüfte Ansicht in
accessibility.json, bei den übrigen Artefakten des Browsertests.
axe-core kommt aus package.json, festgelegt in package-lock.json; führe also vor browser.sh npm ci aus, wie es die CI tut. BROWSER_STEPS=accessibility führt die Prüfung allein aus. Behebe einen Verstoß lieber in der Karte, als ihn hinzunehmen: Gedämpfte Wörter nehmen --ad-muted-text aus den Bausteinen, das den Kontrast in beiden Designs und auf einer getönten Kachel hält, jedes Bedienelement hat Wörter oder ein aria-label, und was scrollt, erreicht auch die Tastatur.
Timer der Karten¶
Eine Karte auf einem Dashboard hat Timer: den Countdown bis zum nächsten Match eines Turniers, die Zeit, die das Ergebnis oder die Zusammenfassung eines Matches zeigt, und den Ruhemodus; die Trainingskarte folgt den Ereignissen des Boards über den History-Stream von Home Assistant. Nur das Verlassen der Seite hält sie an, in disconnectedCallback. Home Assistant kann einer Karte auch danach noch neue Zustände geben, deshalb startet eine Karte einen Timer oder einen Stream nur, solange sie auf der Seite ist (isConnected). Kommt sie zurück, aktualisiert connectedCallback sie, und das startet beides neu.
npm test hält jede Karte daran:
tests/frontend/dom.mjsverfolgt die echten Timer, die die Karten und die Tests stellen. Endet ein Test, enden seine simulierten Timer, und seine Karten verlassen die Seite, sodass der nächste Test ohne eine Karte und einen Timer von ihm beginnt. Ein Timer, der dann noch läuft, würde die Datei für immer laufen lassen. Stattdessen hält er an, und der Test schlägt fehl, mit seinem Namen und dem, was der Timer aufruft:
✖ a scoreboard of a waiting tournament counts down with real timers
Error: The test "a scoreboard of a waiting tournament counts down with real timers" left 1 timer running after its cards left the page:
setInterval(() => this._update(), 1000)
Karten, die eine Datei außerhalb ihrer Tests auf die Seite stellt, verlassen sie nach ihrem letzten Test, und die Datei schlägt genauso mit ihrem Namen fehl.
- Ein Test, der nie endet, schlägt nach 60 Sekunden mit seinem Namen fehl (--test-timeout=60000 in package.json), und die übrigen Dateien laufen weiter.
Simulierte Timer (context.mock.timers) enden mit ihrem Test und zählen nicht. Um zu prüfen, dass eine Karte keinen Timer startet, vergleicht ein Test timers() aus dom.mjs davor und danach, wie es a scoreboard that left the page starts no countdown and no timer of the tournament in tests/frontend/tournament.test.mjs tut.
Die Karten eines Tests verlassen die Seite mit ihm, weil eine Karte, die blieb, npm test auf einem langsamen Rechner zufällig scheitern ließ (#106). Der Countdown eines wartenden Turniers zeichnete eine Karte eines früheren Tests jede Sekunde neu, auch während ein späterer Test setTimeout simulierte. Die Karte löschte ihren Timer des Ruhemodus dann mit der Simulation, die nichts löscht, und der echte Timer lief weiter, nachdem die Karten die Seite verlassen hatten. dom.mjs beendet die Simulation, bevor die Karten gehen, sodass sie ihre echten Timer mit dem echten clearTimeout löschen.
Wann die End-to-End-Tests laufen¶
Der Browsertest mit der Prüfung der Barrierefreiheit gehört zu den End-to-End-Tests in Docker, dem Workflow Docker E2E (e2e.yml). Mit ihren Jobs nebeneinander dauern sie etwa fünf Minuten, länger als die meisten anderen Prüfungen, und die meisten Pull Requests ändern nichts, was sie testen. Deshalb fragt der Job changes zuerst die API, welche Dateien ein Pull Request ändert, ohne Checkout und ohne Action eines Dritten. Die Test-Jobs laufen nur, wenn eine davon zu dem gehört, was die Tests ausführen oder einbinden: die Integration in custom_components/, die Blueprints in blueprints/, die Tests selbst in tests/e2e/, die aufgezeichneten Antworten des Board Managers in tests/fixtures/board_manager/, package.json und package-lock.json mit axe-core und der Workflow .github/workflows/e2e.yml. Der Job live demo entscheidet für sich: Er läuft, wenn ein Pull Request etwas ändert, das die Live-Demo der Website spielt, die Karten in custom_components/homedarts/frontend/, die Seite in docs/demo/ mit docs/branding/ und scripts/mkdocs_hooks.py, ihren Browsertest (tests/e2e/live_demo.py, tests/e2e/live_demo.sh, tests/e2e/requirements-browser.txt) oder den Workflow. Ein Pull Request, der nur die Live-Demo ändert, etwa einer des Screenshot-Bots mit einer neuen Aufzeichnung, startet so nur diesen einen Job und keinen mit Home Assistant. Eine umbenannte Datei zählt auch mit ihrem alten Namen. Scheitert die API oder ändert ein Pull Request mehr Dateien, als die API auflistet (3000), laufen die Tests. Jeder Push auf main, jede Nacht und jeder Lauf auf Abruf testen alles, nachts auch gegen die Beta von Home Assistant.
main verlangt eine einzige Prüfung der End-to-End-Tests, e2e, vom Job gate. Eine Matrix, die eine Bedingung überspringt, meldet keinen ihrer Job-Namen, und ein übersprungener Job zählt als bestanden. Deshalb läuft das Gate immer und entscheidet selbst: Es besteht, wenn jeder Test-Job bestanden hat oder changes nichts zu testen fand, und schlägt fehl, wenn ein Test-Job fehlschlug, abgebrochen oder übersprungen wurde, obwohl er laufen musste. Ein Pull Request, der nur die Dokumentation oder andere Workflows ändert, bekommt e2e etwa eine Minute nach dem Push. tests/test_consistency.py schlägt fehl, wenn eine Compose-Datei eine Datei des Repositorys einbindet oder der Browsertest eine liest, die in dieser Liste fehlt.
Die Test-Jobs laufen nebeneinander. Drei Jobs scenario führen tests/e2e/run.sh aus, auf Board Manager 1, Board Manager 2 und Home Assistant 2026.8.0. Sechs Jobs browser führen den Browsertest aus, drei für das aktuelle und drei für das älteste unterstützte Home Assistant. Jeder Browser-Job lädt die Images von Home Assistant und Playwright zugleich. Der erste startet zwei Worker, BROWSER_SHARD=1/2 mit BROWSER_WORKERS=2, die Karten, Spiele und Formulare neben den gleichbleibenden Höhen; der zweite führt BROWSER_SHARD=3/4 aus, der dritte BROWSER_SHARD=4/4, sodass die vier Teile einer Version zugleich laufen. Die Touchscreens, Safari und die Barrierefreiheit haben einen Runner für sich: Zweimal konnte WebKit von Safari die Skripte von Home Assistant selbst nicht laden, während ein zweiter Browser daneben die neun Bildschirme von every screen size zugleich auf den vier Kernen eines Runners öffnete. tests/test_e2e_shards.py schlägt fehl, wenn der Job mit Safari einen zweiten Worker startet. Ein Schritt, der fehlschlägt, lässt nur seinen Job fehlschlagen, und der Bot für unzuverlässige Tests führt nur diesen Job und das Gate erneut aus. Der Job nennt den Teil und den Schritt, der fehlschlug, in einer Anmerkung und in seiner Zusammenfassung, mit dem Befehl, der den Schritt allein ausführt, etwa BROWSER_STEPS='steady heights' bash tests/e2e/browser.sh. Der nächtliche Job gegen die Beta führt das Szenario und danach jede Gruppe von Schritten nacheinander auf einer Demo-Instanz aus, aus demselben Grund.
Wenn ein Check dieses Projekts fehlschlägt¶
Ein Check, der fehlschlägt, erklärt sich selbst, auch jemandem, der aus einem Fork beiträgt: Der PR-Coach des Blueprints sagt, was er gefunden hat, warum das wichtig ist und wie es sich beheben lässt, mit den Befehlen dazu. Die Checks des Blueprints nimmt er aus .github/checks.toml, die dieses Projekts allein aus .github/checks.project.toml. Schlägt ein Job der End-to-End-Tests oder des Bildvergleichs fehl, schreibt sein letzter Schritt, Explain the failure, die Erklärung in die Zusammenfassung des Jobs und als seine Annotation. Nach den Checks zeigt der Coach sie in seinem einen Kommentar am Pull Request; der Kommentar folgt auch dem Bildvergleich, weil die Antwort test_workflows in .copier-answers.yml ihn nennt.
| Check | Was seine Erklärung nennt | Wie er behoben wird |
|---|---|---|
e2e, den main verlangt |
den Test-Job, der laufen musste und nicht bestand | durch den Job, der fehlschlug |
| scenario (…) | tests/e2e/run.sh mit dem Board Manager des Jobs, und wie das älteste Home Assistant läuft |
von einem Menschen |
| browser (…) | tests/e2e/browser.sh mit dem BROWSER_SHARD des Jobs, und BROWSER_STEPS für einen Schritt |
von einem Menschen |
| live demo | tests/e2e/live_demo.sh, und scripts/record_demo.py für eine Aufnahme, die nicht mehr passt |
von einem Menschen |
| e2e (Home Assistant beta), nachts | das Szenario und den Browsertest mit dem Beta-Image | von einem Menschen |
| render (…) und compare (…) des Bildvergleichs | tests/e2e/visual.sh für die Gruppe und die Sprache |
von einem Menschen; ein Bild, das nur anders aussieht, ist kein Fehler, der Screenshot-Bot rendert es nach dem Merge |
der Schritt Checks of this project von scripts/check.sh |
die Lizenz, ESLint und die Tests der Karten und des Übersetzungs-Bots mit Node | vom Fix-Bot, soweit ein Befehl es kann |
Was ein Befehl beheben kann, behebt der Fix-Bot. Schlägt der Check check (home-assistant) fehl, führt er die Befehle aus autofix jedes Schritts von scripts/check.sh aus; der Schritt Checks of this project nennt die Korrekturen von ESLint an den Karten, python scripts/sync_translations.py und python scripts/ship_blueprints.py. Der Abgleich der Übersetzungen kopiert strings.json nach translations/en.json und bringt die Texte jeder anderen Sprache in die Reihenfolge von strings.json, ohne die, die es dort nicht mehr gibt; ein Text, der einer Sprache fehlt, bleibt für einen Menschen: Schreib ihn in der Sprache, oder bitte im Pull Request darum, dass ein Maintainer oder ein Muttersprachler ihn vor dem Merge ergänzt (Übersetzungen). Nach den Checks committet der Bot die geänderten Dateien auf den Branch des Pull Requests oder schlägt sie bei einem Pull Request aus einem Fork an ihren Zeilen vor, und python3 .github/blueprint.py fix run macht dieselben Korrekturen auf deinem Rechner. Beide erreichen den Schritt Checks of this project, sobald der Blueprint auch einen Schritt innerhalb eines if von scripts/check.sh findet (Dennis-Otto/repo-blueprint#351); bis dahin führst du seine drei Befehle selbst aus.
Ein neuer Job der End-to-End-Tests oder des Bildvergleichs kommt mit seiner Erklärung in .github/checks.project.toml und seinem Schritt Explain the failure. tests/test_pr_coach.py schlägt fehl für einen Job ohne beides, für eine Erklärung ohne autofix, das die Befehle des Fix-Bots nennt oder den Grund, warum kein Befehl den Check beheben kann, und für einen verlangten Check ohne Erklärung.
Aufgezeichnete Antworten des Board Managers¶
Die Unit-Tests und der End-to-End-Test sprechen jeweils mit einem Double des Board Managers: tests/local_helpers.py meldet seine Antworten beim HTTP-Mock der Tests von Home Assistant an, tests/e2e/board_mock.py liefert sie in einem eigenen Container. Beide antworten wie die Aufzeichnungen in tests/fixtures/board_manager/: ein Ordner je Version des Board Managers, 1.0.7, 2.0.0 und 2.0.2, mit einer Datei je Route, die die Integration liest, den Frames des Sockets, während Darts geworfen und herausgezogen werden, und capture.json, das sagt, woher die Aufzeichnung stammt. Das Double des End-to-End-Tests legt die Board-ID und die Schlüssel des Tests und den Zustand des simulierten Boards über seine Aufzeichnung; das der Unit-Tests antwortet genau wie aufgezeichnet, wenn ein Test keine eigenen Antworten vorgibt.
tests/test_board_contract.py hält beide Doubles an die Aufzeichnungen. Es schlägt fehl, wenn das Double des End-to-End-Tests eine Route oder einen Frame mit anderen Schlüsseln oder Typen beantwortet als seine Aufzeichnung, wenn das Double der Unit-Tests etwas anderes antwortet als seine Aufzeichnung, wenn die Integration eine Aufzeichnung ablehnt oder einen ihrer Frames übergeht und wenn eine Aufzeichnung ein Geheimnis enthält. Außerdem zeichnet es das Double des End-to-End-Tests mit dem Aufzeichnungsskript auf und vergleicht das Ergebnis mit der Aufzeichnung.
Ein echtes Board zeichnet python scripts/capture_board.py <Adresse des Board-PCs> auf. Das Skript liest jede Route, zeichnet den Socket eine Minute lang auf (--seconds, oder --frames für eine Anzahl Frames), ersetzt die Board-ID, den API-Schlüssel, den TLS-Schlüssel und das Zertifikat, Name und Adresse des Board-PCs und jede private IPv4-Adresse durch dokumentierte Platzhalter wie board-1 und private-board-api-key und schreibt den Ordner der Version, die das Board meldet. Es schreibt nichts, das noch einen davon enthält. Die heutigen Aufzeichnungen sind aus den Formen gebaut, gegen die die Integration entwickelt wurde ("captured": null); eine Aufzeichnung eines echten Boards ersetzt sie, und ein Kompatibilitätsbericht fragt danach. Ein neuer Ordner braucht keine Änderung der Tests: Jeder Test des Vertrags nimmt ihn auf.
Netzwerk der Jobs¶
Jeder Job beginnt mit Harden-Runner. In den projekteigenen Workflows, den End-to-End-Tests, dem Bildvergleich und seinem Kommentar, dem Screenshot-Bot und dem Übersetzungsbot, sperrt er jeden Host, den die Schritte des Jobs nicht brauchen (egress-policy: block), damit eine kompromittierte Action, ein Paket oder ein Test kein Token und keine Daten anderswohin schicken kann. Ein Job, der keinen Container startet, läuft außerdem ohne sudo und Docker, damit kein Schritt die Sperre aufheben kann. Die Dienste von GitHub Actions selbst, etwa den Speicher der Artefakte und Caches, lässt Harden-Runner von sich aus zu.
| Jobs | Hosts | Wofür |
|---|---|---|
| Übersetzungsbot, pull request des Screenshot-Bots | api.github.com, github.com |
Sie dürfen Issues und Branches schreiben, also erreichen sie nur GitHub. |
| Kommentar des Bildvergleichs | api.github.com |
Er liest die Berichte der Prüfung und schreibt den Kommentar, sonst nichts. |
| changes der End-to-End-Tests | api.github.com |
die Dateien des Pull Requests |
| scenario | github.com, ghcr.io, pkg-containers.githubusercontent.com |
der Code und das Image von Home Assistant |
| browser und die nächtliche Beta | dieselben, mcr.microsoft.com, *.data.mcr.microsoft.com, registry.npmjs.org, pypi.org, files.pythonhosted.org |
das Image von Playwright, axe-core und die Python-Pakete des Browsertests |
| render des Bildvergleichs und des Screenshot-Bots | wie browser ohne npm, dazu launchpad.net, launchpadlibrarian.net, brands.home-assistant.io |
pngquant und die Symbole der anderen Integrationen von Home Assistant, die die Bilder zeigen |
| compare und collect | github.com, pypi.org, files.pythonhosted.org |
Pillow für den Vergleich |
| live demo der End-to-End-Tests | github.com, mcr.microsoft.com, *.data.mcr.microsoft.com, pypi.org, files.pythonhosted.org |
das Image von Playwright und die Python-Pakete des Browsertests |
| timeline des Screenshot-Bots | github.com, pypi.org, files.pythonhosted.org |
die Pakete der Tests, deren Home Assistant die Spiele der Live-Demo spielt; Python kommt vom Runner |
| das Gate e2e | github.com |
Es liest die Ergebnisse der anderen Jobs und holt nach einem Fehlschlag .github/ für die Erklärungen des PR-Coachs. |
Das Home Assistant der Tests ruft selbst nichts im Internet auf. Sein Onboarding richtet Radio Browser ein, das gleich beim Start Radiosender im Internet nachschlägt, bevor ein Test es entfernen könnte. Deshalb nimmt tests/e2e/offline/radio_browser als benutzerdefinierte Integration im Container seinen Platz ein, und sein Konfigurationsablauf lehnt das Angebot ab; das Szenario prüft, dass kein Eintrag von Radio Browser existiert. Nur die Demo der Bilder lädt die Symbole der eingebauten Integrationen, die die Integrationsseite zeigt, über den Proxy von Home Assistant aus dessen CDN.
Ein Host, den ein Job nicht nennt, lässt seinen Schritt fehlschlagen, etwa mit einer DNS-Abfrage, die scheitert, oder einer abgewiesenen Verbindung. Den Host selbst nennt das Protokoll des letzten Schritts des Jobs, Post Block every host that the job doesn't need, als domain not allowed: <host>, und die Seite der Insights von StepSecurity, die Harden-Runner in seinem ersten Schritt verlinkt. Ein neuer Host kommt mit dem Schritt, der ihn braucht, in allowed-endpoints des Jobs, mit einem Kommentar, der sagt, wofür, und in tests/test_workflow_network.py. Dieser Test schlägt fehl für einen Job dieser Workflows, den er nicht kennt, einen Host, den ein Job nicht braucht, einen Job, der wieder nur aufzeichnet (audit), einen Job ohne Container, der sudo behält, und einen Job, der schreiben darf und mehr als GitHub erreicht. Harden-Runner filtert die Adressen des Runners und des Standardnetzes von Docker; die Container der Compose-Netze bekommen ihre Adressen von seinem DNS, das nur die Hosts des Jobs auflöst. Die Workflows, die der Blueprint pflegt, folgen dessen eigenen Regeln.
Das Board und seine Spiele¶
Drei Klassen teilen sich ein Board. AutodartsLocalCoordinator in local_coordinator.py ist die Verbindung: Er folgt dem Board Manager mit seinen Echtzeitereignissen und Abfragen, befreit hängende Takeouts, meldet die Reparaturhinweise des Boards und führt seine Aktionen aus. BoardGames in games.py ist die Spiel-Engine und weiß nichts von Home Assistant: die Trainingssession, das Übungsspiel mit dem Fortschritt und den persönlichen Bestleistungen der Spieler, das Turnier, die von Hand eingegebenen Darts und der Bot. Es folgt den Darts eines Zustands des Boards, bucht die Aufnahmen, behält, was ein Rückgängig braucht, und speichert und lädt sich selbst. Jeder Schritt gibt die Board-Ereignisse zurück, die er bringt, in ihrer Reihenfolge, und bekommt die Zeit als Parameter. AutodartsGamesCoordinator in games_coordinator.py spielt es in Home Assistant: Er meldet die Ereignisse, speichert die Spiele mit BoardStore in storage.py und führt die Timer der Spiele, etwa für das Ende einer ruhenden Session, das nächste Match eines Turniers, die Darts des Bots und die Zielmodelle der Spieler. Zwei Koordinatoren erweitern ihn nebeneinander: Der lokale Koordinator fügt den Board Manager hinzu, und AutodartsManualCoordinator in manual_coordinator.py ist eine Dartscheibe ohne Autodarts, ganz ohne Client, Echtzeitereignisse und Abfragen.
Entitäten lesen die Spiele über coordinator.games, etwa coordinator.games.practice, und ändern sie nur über die Aktionen des Koordinators, etwa async_start_game, die die Änderung speichern und melden. Eine Regel der Spiele gehört in BoardGames oder in eine Klasse darin, damit tests/test_games.py sie ohne Home Assistant testet, in Millisekunden statt in den Sekunden eines Boards, das in Home Assistant eingerichtet wird. games.py importiert nichts von Home Assistant, was dieser Test prüft. Die Klassen darin sind noch nicht so weit: Training, Übungsspiel, Profile und Drills lesen die Uhr noch mit homeassistant.util.dt, und das Turnier löst ServiceValidationError noch selbst aus. Eine Aktion, die BoardGames nicht ausführen kann, löst ActionRefused mit dem Schlüssel ihrer Meldung aus, und der Koordinator macht daraus den ServiceValidationError mit dieser Meldung.
Code, der den Board Manager braucht, fragt nach dem Typ AutodartsLocalCoordinator, und die Typprüfung hält ihn dazu an: runtime.local ist ein AutodartsGamesCoordinator, die Entitäten der Spiele nehmen jeden Koordinator der Spiele, und die Entitäten des Board Managers, etwa seine Kameras, Steuerungen und Messwerte, sind eine AutodartsBoardEntity, die die Einrichtung jeder Plattform erst nach isinstance(coordinator, AutodartsLocalCoordinator) hinzufügt. Keine Änderung der Verbindung kann eine Dartscheibe ohne Autodarts erreichen, wie die Entscheidung 0018 erklärt.
Das Übungsspiel und seine Modi¶
PracticeGame in practice.py ist der Rahmen jedes Matches am Board: die Spieler, ihre Plätze und Teams, der Bot, das Ausbullen, Legs und Sets, die Bilanz des Matches, das Rückgängigmachen und der Speicher. Das Spiel selbst ist sein Modus aus modes.py: X01Mode, CricketMode und PartyMode spielen ein Match, DrillMode spielt ein Trainingsspiel, und GameMode selbst ist kein Spiel. Der Rahmen gibt jeden Schritt, der vom Spiel abhängt, an seinen Modus: den Darts einer Aufnahme folgen (track), sie buchen (book), den nächsten Zug (turn_details), den Zustand, den die Karten zeigen (snapshot), was ein Leg, ein Match und seine Zusammenfassung festhalten (book_leg, leg_entry, match_entry und summary_entry), und die Auswertung einer Aufnahme (booking). Nur die Modus-Fabrik PracticeGame._mode kennt die Spiele. Ein Modus behält, was nur sein Spiel hat, etwa die Zahlen, die Random Cricket gezogen hat, oder den Stand eines Partyspiels, und der Speicher behält den Aufbau, den er vor den Modi hatte, was tests/test_modes.py Byte für Byte an Speichern von vor den Modi prüft, in tests/fixtures/stores_before_modes.json.
Ein neues Spiel ist ein neuer Modus, für ein Party- oder Trainingsspiel ein neues PartyGame in party.py oder ein neuer Drill in drills.py, die PartyMode und DrillMode spielen: eine Klasse in modes.py mit den Schritten, in denen es sich von GameMode unterscheidet, und eine Zeile in der Modus-Fabrik. Eine Einstellung, mit der ein Spiel entsteht, etwa die Löcher von Golf, gehört in GAME_SETTINGS, damit eine Änderung das Spiel neu beginnt, während es läuft. Warum, erklärt die Entscheidung 0021 (auf Englisch).
Texte der Karten¶
Die Texte der Karten sind ein Modul je Sprache in custom_components/homedarts/frontend/text/, export default gefolgt von JSON. So bearbeitet eine Übersetzerin eine kleine Datei, und jedes Werkzeug liest sie gleich: Die Konsistenztests, der Browsertest und der Übersetzungs-Bot lassen die Zeilen mit Kommentaren weg und lesen den Rest als JSON. homedarts-card.js importiert jede Datei und fügt sie in TEXT zusammen; tests/test_consistency.py prüft die Dateien, ihre Importe und ihre Schlüssel (Entscheidung 0019, auf Englisch).
Die Integration liefert jede Datei des Ordners unter /homedarts/<Version>-<Prüfsumme>/<Pfad> aus, komprimiert, und gibt jeder Seite beide Module unter diesen Adressen (card.py). Relative Importe behalten den Ordner. Ändert sich eine Datei, auch nur ein Text, bekommen alle neue Adressen, und kein Browser behält einen alten Text neben einer neuen Karte. Der Ordner einer vergangenen Version und die Adressen ohne Version liefern die heutigen Dateien, die Browser bei jedem Laden neu anfragen.
Blueprints im Release-Paket¶
HACS installiert das Paket eines Release, das nur den Ordner der Integration enthält. Deshalb reisen die Blueprints in custom_components/homedarts/blueprints/ mit, einer Kopie von blueprints/ (Entscheidung 0023, auf Englisch). custom_components/homedarts/blueprint_versions.json hält einen Fingerabdruck jeder Version jedes Blueprints, die es auf main gab. Daran erkennt die Integration einen Blueprint, den niemand geändert hat, und aktualisiert ihn, siehe shipped_blueprints.py und die Updates der Blueprints. Nach einer Änderung an einem Blueprint:
python scripts/ship_blueprints.py
Das Skript kopiert die Blueprints und ergänzt den Fingerabdruck jeder neuen Version; --history ergänzt auch die jeder Version in Git. tests/test_shipped_blueprints.py scheitert mit diesem Befehl, bis Kopie und Fingerabdrücke da sind. Ein Fingerabdruck bleibt für immer in der Datei, damit die Integration Installationen jeder früheren Version erkennt.
Mutationstests¶
Tests können eine Zeile ausführen, ohne zu prüfen, was sie tut. Jeden Samstag findet der Workflow Mutation (mutation.yml, den der Blueprint pflegt) solche Zeilen: mutmut ändert den Code auf Tausende kleine Arten, die Mutanten, etwa <= statt < oder eine andere Zahl, und führt gegen jeden Mutanten die Tests aus, die die geänderte Funktion aufrufen. Ein Test, der scheitert, erledigt den Mutanten; ein Mutant, den jeder Test durchlässt, überlebt und zeigt auf eine Zeile, die die Tests ausführen, aber nicht prüfen. Die Zusammenfassung des Laufs zeigt den Wert, den Anteil der Mutanten, die ein Test erledigt hat, und die überlebenden Mutanten; ein niedriger Wert lässt nichts fehlschlagen. Der Workflow läuft auch auf Abruf über den Reiter Actions.
[tool.mutmut] in pyproject.toml legt fest, was sich ändert. only_mutate nennt acht Module der Spielregeln: die Scheibe, die Checkouts, die Punkte der Darts, die Doubles, die Positionen, Cricket, den Bot und die Partyspiele. Die meisten Tests prüfen sie direkt, und ein Lauf auf den vier Kernen eines Runners erreicht alle ihre 2.804 Mutanten in unter zwei Stunden. Das X01-Übungsspiel, die Trainingsspiele und die Turniere kämen mit etwa 5.900 Mutanten dazu, mehr, als die 320 Minuten eines Laufs erlauben; sie folgen, sobald der Workflow den Lauf in Teile aufteilt. Der Mutation-Lauf vom 13. Oktober 2026 hat 1 h 51 min gedauert: Er hat 2.503 der 2.804 Mutanten erledigt, 294 überleben lassen und 7 am Zeitlimit gestoppt, ein Wert von 89 %. Die meisten Überlebenden stecken in den Partyspielen, im Bot und in den Positionen.
Vier Regeln halten die Tests unter mutmut richtig, und tests/test_mutation.py prüft jede davon:
- Die Kopie. mutmut führt die Tests in seiner Kopie des Repositorys aus,
mutants/, mit dem geänderten Code, den Dateien und Ordnern ausalso_copyund ohne Git. Ein Test, der den Code als Text liest, etwa die Prüfung, dass jede Meldung ausgelöst wird, oder Git fragt, scheitert dort bei jedem Mutanten. Er bekommt@pytest.mark.mutation_unsafe, undtests/conftest.pyüberspringt ihn, sobald mutmutMUTANT_UNDER_TESTgesetzt hat.also_copynennt jede Datei und jeden Ordner oben im Repository, die Git verfolgt, außer der Integration, und dazu die Dateien und Ordner, die kommende Blueprint-Updates hinzufügen, denn mutmut überspringt einen Pfad, den es nicht gibt. Fehlt ein neuer, scheitert ein Test und nennt die Einträge, die dazukommen müssen. - Die Caches. mutmut führt zuerst jeden Test einmal aus und merkt sich die Funktionen, die er aufruft, dann die Tests jedes Mutanten in einem Prozess, der von diesem ersten Lauf abzweigt. Ein Ergebnis, das ein functools-Cache dort behalten hat, stammt aus dem Code ohne den Mutanten und verdeckt ihn, und ein Test, der sein Ergebnis im Cache findet, wird nicht gemerkt. Deshalb leert die Fixture
fresh_cachesvor jedem Test die Caches der Integration, etwa die voncheckout.py; das kostet keine Zeit, die sich zeigt. - Die Decorators. mutmut lässt eine Funktion mit Decorator unverändert, außer statischen und Klassenmethoden. Deshalb umhüllt ein functools-Cache seine Funktion, statt sie zu dekorieren,
checkout = lru_cache(maxsize=8192)(_uncached_checkout), und ein Test scheitert, wenn ein Modul ausonly_mutateeine Funktion mit etwas anderem als diesen,propertyoderabstractmethoddekoriert. Die Properties bleiben unverändert. - Die Parameter. mutmut führt jeden Test in seinem ersten Prozess mehrmals aus, um sich die Funktionen zu merken, die er aufruft, und dann ohne jeden Mutanten, und zweigt den Prozess jedes Mutanten davon ab. Ein Parameter, den ein Lauf geändert hat, erreicht so jeden späteren Lauf, so wie wenn eine IDE einen gescheiterten Test noch einmal ausführt. Ein Test, der seine
parametrize-Werte ändert, scheitert deshalb sofort; ändere stattdessen eine Kopie (setup = dict(setup)).
Selbst ausführen lassen sie sich unter Linux oder macOS (mutmut nutzt fork, unter Windows also in WSL oder Docker):
.venv/bin/pip install --require-hashes --no-deps -r .github/mutation-requirements.txt
.venv/bin/mutmut run "custom_components.homedarts.checkout*"
.venv/bin/mutmut results
.venv/bin/mutmut show <Name eines Mutanten>
Unter mutmut ziehen die Property-Tests dieselben Beispiele wie in CI, damit ein Mutant auf jedem Rechner auf dieselbe Weise erledigt wird oder überlebt, und jeder Test bekommt das Zehnfache seines Zeitlimits: Jeder Aufruf einer geänderten Funktion läuft durch ein Trampolin von mutmut, das die langsamsten Tests um ein Mehrfaches verlangsamt, und einen Mutanten, der hängt, hält mutmut mit einem eigenen Limit an. mutmut behält seine Kopie und seine Ergebnisse in mutants/; lösche den Ordner danach.