Files
als-alert-screen/test/testy_ekranu.py
T
sases 199e71327f Przejście na kanał zbiorczy LuPort + naprawa reconnectu, watchdog, widoczny offline
PRZYCZYNA ZNIKAJĄCYCH ALARMÓW ZNALEZIONA — i nie była nią liczba połączeń.
Commit 7cf0634 ("hidden status-bar & machine.status") zakomentował element
#status-bar w HTML, ale refreshStatus() nadal po niego sięgał. TypeError na
null przerywał handler ws.onclose PRZED zaplanowaniem reconnectu — każda
maszyna po pierwszym zerwaniu połączenia milkła NA ZAWSZE. Restart z crona
co 2 h maskował problem, zaczynając od zera.

Czerwcowe poprawki na kiosku (timery świeżości per-maszyna, ubijanie "zombie"
połączeń, jitter) szły w dobrą stronę, ale nieświadomie POGORSZYŁY objaw:
watchdog celowo zamykał ciche połączenie, a każde zamknięcie przechodziło
przez ten sam wadliwy onclose — własna obrona uśmiercała maszynę na stałe.
Ich sedno jest zachowane w tej wersji (wykrywanie ciszy, jitter, nakładkowy
iframe z "iframe optimalization").

NOWA ARCHITEKTURA (pod nowy system LuPort):
- JEDNO połączenie na kanał zbiorczy /ws/als-data/ zamiast 35 per-maszyna;
  serwer wysyła migawkę stanu wszystkich maszyn zaraz po połączeniu (dodane
  w LuPort równolegle), więc trwający alarm wraca na ekran natychmiast po
  reconnexie
- token ApiUser w adresie (nowy system wymaga uwierzytelnienia WS); konto
  bot-als-alert-screen bez żadnych uprawnień
- reconnect z wykładniczym odstępem (1->30 s) i jitterem; odstęp wraca do
  minimum dopiero po 60 s stabilnego połączenia
- watchdog ciszy > 90 s (przeglądarka nie wystawia ping/pong do JS — martwe
  półotwarte połączenie inaczej wisi w nieskończoność)
- dane maszyny starsze niż 5 min znikają z ekranu; przy rozłączeniu dane NIE
  są czyszczone od razu (alarm nie znika przy 3-sekundowym czknięciu sieci —
  o niepewności mówi pomarańczowy pasek, migawka po reconnexie przywraca
  prawdę)
- utrata łączności WIDOCZNA: pomarańczowy pasek po 10 s + przywrócony pasek
  statusu; element MUSI istnieć w DOM, refreshStatus() ma mimo to osłonę
- MACHINES stało się filtrem wyświetlania; ?ws= i ?token= nadpisują
  konfigurację z adresu strony
- karty przez textContent; celowo BRAK ws.onerror (po błędzie przeglądarka
  i tak wywołuje onclose)

TESTY (test/testy_ekranu.py): 10 deterministycznych scenariuszy bez sieci —
skrypt wycinany z żywego HTML, WebSocket podmieniony fałszywką, wirtualny
czas Chromium. Scenariusz 6 to bezpośredni test regresji błędu reconnectu;
mutacja (usunięcie planowania reconnectu) wywraca scenariusze 6, 8 i 9.

CRON RESTARTUJĄCY CO 2 H MOŻNA USUNĄĆ — README opisuje czemu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:04:30 +02:00

202 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Testy ekranu alarmowego — deterministyczne, bez sieci i bez serwera.
Skrypt aplikacji jest wycinany z ŻYWEGO als_alert_screen.html (nie z kopii,
więc testy nie mogą się rozjechać z kodem), a `window.WebSocket` podmieniany
na fałszywkę sterowaną scenariuszem. Cały przebieg — łącznie z 90-sekundowym
watchdogiem i 5-minutowym wygasaniem danych — jedzie na wirtualnym czasie
Chromium (`--virtual-time-budget`), więc trwa sekundy.
Uruchomienie:
python3 test/testy_ekranu.py
CHROME_BIN=/usr/bin/chromium python3 test/testy_ekranu.py
Kod wyjścia 0 = wszystkie scenariusze PASS.
Dlaczego to istnieje: poprzednia wersja strony miała błąd, przez który KAŻDA
maszyna po pierwszym zerwaniu połączenia milkła na zawsze (wyjątek w onclose
przerywał handler przed zaplanowaniem reconnectu — patrz README, Historia).
Objaw „alarmy przestały wyskakiwać" był leczony restartem z crona co 2 h.
Scenariusz 6 jest bezpośrednim testem regresji tego błędu.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
APP = Path(__file__).resolve().parent.parent / "als_alert_screen.html"
SCENARIUSZ = """
const wyniki = [];
function assert(nazwa, warunek) {
wyniki.push((warunek ? "PASS " : "FAIL ") + nazwa);
document.getElementById("wynik").textContent = wyniki.join("\\n");
}
const alarmActive = () => document.getElementById("alarm-view").classList.contains("active");
const cards = () => [...document.querySelectorAll(".card-machine-id")].map(e => e.textContent);
const bannerActive = () => document.getElementById("offline-banner").classList.contains("active");
const inst = () => FakeWebSocket.instances;
setTimeout(() => {
assert("1. polaczenie nawiazane", inst().length === 1 && inst()[0].readyState === 1);
inst()[0]._message({type:"machine_data", machine:"2.60", data:{status:"Alarm - awaria"}});
inst()[0]._message({type:"machine_data", machine:"3.22", data:{status:"Automatyczny"}});
}, 50);
setTimeout(() => {
assert("2. migawka: widok alarmowy z 2.60", alarmActive() && cards().join()==="2.60");
inst()[0]._message({type:"machine_update", machine:"3.22", data:{status:"Alarm - przekroczony cykl"}});
}, 100);
setTimeout(() => {
assert("3. update na zywo: dwie karty", alarmActive() && cards().join()==="2.60,3.22");
inst()[0]._message({type:"machine_update", machine:"2.60", data:{status:"Automatyczny"}});
inst()[0]._message({type:"machine_update", machine:"3.22", data:{status:"Automatyczny"}});
}, 150);
setTimeout(() => {
assert("4. koniec alarmow: powrot do iframe", !alarmActive());
inst()[0]._message({type:"machine_update", machine:"9.99", data:{status:"Alarm - obca"}});
}, 200);
setTimeout(() => {
assert("5. maszyna spoza filtra ignorowana", !alarmActive());
FakeWebSocket.autoOpen = false;
inst()[0]._serverClose();
}, 250);
// KLUCZOWY test regresji: po zamknięciu MUSI powstać nowe połączenie.
setTimeout(() => {
assert("6. reconnect zaplanowany i wykonany", inst().length >= 2);
}, 5000);
setTimeout(() => {
assert("7. baner offline widoczny po 10 s", bannerActive());
FakeWebSocket.autoOpen = true;
inst()[inst().length-1]._open();
inst()[inst().length-1]._message({type:"machine_data", machine:"4.09", data:{status:"Alarm - test"}});
}, 16000);
setTimeout(() => {
assert("8. po powrocie: baner znika, alarm 4.09", !bannerActive() && cards().join()==="4.09");
}, 17000);
setTimeout(() => { window.__przedWatchdog = inst().length; }, 18000);
setTimeout(() => {
assert("9. watchdog: cisza 100s wymusza nowe polaczenie", inst().length > window.__przedWatchdog);
}, 125000);
setTimeout(() => {
assert("10. przeterminowany alarm znika z ekranu", !alarmActive());
document.getElementById("wynik").textContent += "\\nKONIEC";
}, 340000);
"""
FAKE_WS = """
class FakeWebSocket {
static instances = [];
static autoOpen = true;
constructor(url) {
this.url = url;
this.readyState = 0;
FakeWebSocket.instances.push(this);
if (FakeWebSocket.autoOpen) setTimeout(() => this._open(), 5);
}
_open() {
if (this.readyState !== 0) return;
this.readyState = 1;
this.onopen && this.onopen();
}
_message(obj) {
this.onmessage && this.onmessage({ data: JSON.stringify(obj) });
}
_serverClose() {
if (this.readyState === 3) return;
this.readyState = 3;
this.onclose && this.onclose();
}
close() {
if (this.readyState === 3) return;
this.readyState = 3;
setTimeout(() => this.onclose && this.onclose(), 1);
}
}
FakeWebSocket.OPEN = 1;
window.WebSocket = FakeWebSocket;
window.WebSocket.OPEN = 1;
"""
def zbuduj_harness() -> str:
app = APP.read_text(encoding="utf-8")
skrypt = re.search(r"<script>([\s\S]*)</script>", app).group(1)
markup = re.search(r"<body>([\s\S]*?)<script>", app).group(1)
# iframe bez realnego adresu — test działa offline
markup = re.sub(r'src="http://[^"]*"', 'src="about:blank"', markup)
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
#alarm-view{{display:none}} #alarm-view.active{{display:flex}}
#offline-banner{{display:none}} #offline-banner.active{{display:block}}
</style></head><body>
{markup}
<pre id="wynik"></pre>
<script>{FAKE_WS}</script>
<script>{skrypt}</script>
<script>{SCENARIUSZ}</script>
</body></html>"""
def znajdz_chromium() -> str:
for kandydat in (
os.environ.get("CHROME_BIN"),
shutil.which("chromium"),
shutil.which("chromium-browser"),
shutil.which("google-chrome"),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
):
if kandydat and Path(kandydat).exists():
return kandydat
sys.exit("Nie znaleziono Chromium/Chrome — ustaw CHROME_BIN.")
def main() -> int:
chrome = znajdz_chromium()
with tempfile.TemporaryDirectory() as tmp:
harness = Path(tmp) / "harness.html"
harness.write_text(zbuduj_harness(), encoding="utf-8")
wynik = subprocess.run(
[
chrome,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--virtual-time-budget=350000",
"--dump-dom",
f"file://{harness}",
],
capture_output=True,
text=True,
timeout=180,
)
m = re.search(r'<pre id="wynik">([\s\S]*?)</pre>', wynik.stdout)
if not m:
print("BŁĄD: brak wyników w DOM — harness nie wystartował?")
print(wynik.stdout[:500])
return 2
linie = [l for l in m.group(1).strip().splitlines() if l]
for linia in linie:
print(linia)
if "KONIEC" not in linie:
print("BŁĄD: scenariusz nie dobiegł końca (za mały virtual-time-budget?)")
return 2
return 1 if any(l.startswith("FAIL") for l in linie) else 0
if __name__ == "__main__":
sys.exit(main())