"""Tests für die Dienste-Liste mit Kennzahlen (services/dienste.py) — mit echter `systemctl show`-Ausgabe der Box vom 25.09.2026 (/proc/uptime stand bei 80147,36 s).""" import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from services import dienste JETZT = 80147.36 # systemctl --user show mc2-gateway mission-control-2 mc2-steward hermes-gateway hermes-builtin-ui voice-service # lucy-stimme -p Id,LoadState,ActiveState,UnitFileState,MemoryCurrent,CPUUsageNSec,ActiveEnterTimestampMonotonic, # InactiveEnterTimestampMonotonic,NRestarts USER_AUSGABE = """\ Id=mc2-gateway.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=79813970006 InactiveEnterTimestampMonotonic=79813968344 NRestarts=0 MemoryCurrent=45731840 CPUUsageNSec=1568208000 Id=mission-control-2.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=79816961421 InactiveEnterTimestampMonotonic=79816959875 NRestarts=0 MemoryCurrent=198352896 CPUUsageNSec=5660312000 Id=mc2-steward.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=79813795101 InactiveEnterTimestampMonotonic=79813794078 NRestarts=0 MemoryCurrent=56500224 CPUUsageNSec=1002999000 Id=hermes-gateway.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=76113569939 InactiveEnterTimestampMonotonic=76113568536 NRestarts=0 MemoryCurrent=421068800 CPUUsageNSec=45597050000 Id=hermes-builtin-ui.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=76110768028 InactiveEnterTimestampMonotonic=76110766393 NRestarts=0 MemoryCurrent=162058240 CPUUsageNSec=20446657000 Id=voice-service.service LoadState=loaded ActiveState=inactive UnitFileState=disabled ActiveEnterTimestampMonotonic=0 InactiveEnterTimestampMonotonic=0 NRestarts=0 MemoryCurrent=[not set] CPUUsageNSec=[not set] Id=lucy-stimme.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=10066410 InactiveEnterTimestampMonotonic=0 NRestarts=0 MemoryCurrent=7500505088 CPUUsageNSec=502624087000 """ # systemctl show llama-swap -p … (System-Dienst) SYSTEM_AUSGABE = """\ Id=llama-swap.service LoadState=loaded ActiveState=active UnitFileState=enabled ActiveEnterTimestampMonotonic=74032986745 InactiveEnterTimestampMonotonic=74032938183 NRestarts=0 MemoryCurrent=3722850304 CPUUsageNSec=31617381000 """ USER_UNITS = ["mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme"] # Ein abgestürzter Dienst, wie systemd ihn nach Restart=always mit zu vielen Fehlversuchen stehen lässt. GESCHEITERT = { "Id": "hermes-gateway.service", "LoadState": "loaded", "ActiveState": "failed", "UnitFileState": "enabled", "ActiveEnterTimestampMonotonic": "76113569939", "InactiveEnterTimestampMonotonic": "80000000000", "NRestarts": "5", "MemoryCurrent": "[not set]", "CPUUsageNSec": "[not set]", } # --- systemctl show lesen ------------------------------------------------------------------------------------------ def test_liest_die_bloecke_der_echten_ausgabe_in_der_reihenfolge_der_units(): stand = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS) assert list(stand) == USER_UNITS assert stand["lucy-stimme"]["MemoryCurrent"] == "7500505088" assert stand["voice-service"]["UnitFileState"] == "disabled" def test_ordnet_nach_id_zu_wenn_ein_block_fehlt(): bloecke = dienste.lies_show(SYSTEM_AUSGABE) assert dienste.zuordnen(bloecke, ["llama-swap", "gibt-es-nicht"]) == {"llama-swap": bloecke[0]} @pytest.mark.parametrize(("wert", "erwartet"), [ ("198352896", 198352896), ("0", 0), ("[not set]", None), ("infinity", None), ("", None), (None, None), ("18446744073709551615", None), # altes systemd: UINT64_MAX heißt „nicht gezählt“ ]) def test_zahl(wert, erwartet): assert dienste.zahl(wert) == erwartet # --- Auswertung ---------------------------------------------------------------------------------------------------- def test_kennzahlen_eines_laufenden_dienstes(): mc2 = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["mission-control-2"] assert dienste.kennzahlen(mc2, JETZT) == { "speicher_bytes": 198352896, "laeuft_seit_s": 330, "aus_seit_s": None, "neustarts": 0, } def test_kennzahlen_eines_schlafenden_und_eines_gescheiterten_dienstes(): voice = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["voice-service"] assert dienste.kennzahlen(voice, JETZT) == { "speicher_bytes": None, "laeuft_seit_s": None, "aus_seit_s": None, "neustarts": 0, } k = dienste.kennzahlen(GESCHEITERT, JETZT) assert k["laeuft_seit_s"] is None and k["aus_seit_s"] == 147 and k["neustarts"] == 5 def test_zustand_in_worten(): laeuft = {"LoadState": "loaded", "ActiveState": "active"} assert dienste.zustand(laeuft, ok=True, schlaeft=False) == "laeuft" assert dienste.zustand(laeuft, ok=False, schlaeft=False) == "antwortet_nicht" assert dienste.zustand(GESCHEITERT, ok=False, schlaeft=False) == "gescheitert" assert dienste.zustand({**laeuft, "ActiveState": "activating"}, ok=False, schlaeft=False) == "startet" assert dienste.zustand({**laeuft, "ActiveState": "deactivating"}, ok=False, schlaeft=False) == "stoppt" assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=False) == "gestoppt" assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=True) == "schlaeft" def test_zustand_ohne_systemd_nur_nach_erreichbarkeit(): """Windows (Entwicklung) oder eine Unit, die systemd nicht kennt: wie vor dem 25.09. nur „antwortet (nicht)“.""" assert dienste.zustand({}, ok=True, schlaeft=False) == "laeuft" assert dienste.zustand({}, ok=False, schlaeft=False) == "antwortet_nicht" assert dienste.zustand({"LoadState": "not-found", "ActiveState": "inactive"}, ok=True, schlaeft=False) == "laeuft" def test_cpu_anteil_bezogen_auf_alle_kerne(): assert dienste.cpu_anteil((0.0, 0), (10.0, 3_200_000_000), kerne=32) == 1.0 # 0,32 Kerne von 32 assert dienste.cpu_anteil((0.0, 0), (1.0, 64_000_000_000), kerne=32) == 100.0 # mehr geht nicht assert dienste.cpu_anteil((5.0, 900), (5.0, 1000), kerne=32) is None # keine Zeit vergangen assert dienste.cpu_anteil((0.0, 5_000), (10.0, 1_000), kerne=32) is None # Zähler neu (Neustart) def test_cpu_messung_haelt_den_letzten_wert_bei_zu_kurzem_abstand(): m = dienste.CpuMessung(kerne=32) m.probe("hermes-gateway", 100.0, 0) assert m.wert("hermes-gateway") is None # erste Probe: noch nichts zu messen m.probe("hermes-gateway", 100.5, 160_000_000) assert m.wert("hermes-gateway") == 1.0 # 0,16 s CPU in 0,5 s auf 32 Kernen m.probe("hermes-gateway", 101.0, 1_000_000_000) assert m.wert("hermes-gateway") == 1.0 # nur 0,5 s später: Wert bleibt, Probe auch m.probe("hermes-gateway", 110.5, 160_000_000 + 32_000_000_000) assert m.wert("hermes-gateway") == 10.0 # 32 s CPU in 10 s = 3,2 Kerne m.probe("hermes-gateway", 120.5, 5_000) assert m.wert("hermes-gateway") is None # neu gestartet: Zähler fing von vorn an m.probe("hermes-gateway", 121.0, 25_000) assert m.wert("hermes-gateway") == 0.0 m.probe("hermes-gateway", 121.0 + dienste.CPU_MAX_S + 1, 50_000) assert m.wert("hermes-gateway") is None # zu lange niemand geschaut: neu ansetzen m.probe("hermes-gateway", 400.0, None) assert m.wert("hermes-gateway") is None # gestoppt # --- Die ganze Liste ----------------------------------------------------------------------------------------------- @pytest.fixture def box(monkeypatch): """Die Box vom 25.09.: alle Dienste laufen, die Spracherkennung schläft. Die erste Abfrage liest bei JETZT und — kurze zweite Probe — eine halbe Sekunde später, die nächste zehn Sekunden danach.""" uhr = iter([JETZT, JETZT, JETZT + 0.5, JETZT + 0.5, JETZT + 10.5, JETZT + 10.5]) monkeypatch.setattr(dienste, "_uhr", lambda: next(uhr)) monkeypatch.setattr(dienste, "_cpu", dienste.CpuMessung(kerne=32)) monkeypatch.setattr(dienste, "CPU_KURZ_S", 0) monkeypatch.setattr(dienste, "V1_UPSTREAM", "http://127.0.0.1:9010") monkeypatch.setattr(dienste, "agent_status", lambda: { "gateway_reachable": True, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"}) monkeypatch.setattr(dienste, "engine_reachable", lambda: True) monkeypatch.setattr(dienste, "gateway_reachable", lambda: True) monkeypatch.setattr(dienste, "_voice_antwortet", lambda: False) monkeypatch.setattr(dienste.system, "_gpu_sysfs", lambda: {"busy_percent": 3, "gtt_used": 27611533312}) monkeypatch.setattr(dienste, "_speicher_gesamt", lambda: 131738611712) lesungen = {"user": 0, "system": 0} def show(units, system_dienst): art = "system" if system_dienst else "user" text = SYSTEM_AUSGABE if system_dienst else USER_AUSGABE stand = dienste.zuordnen(dienste.lies_show(text), units) # Die Engine rechnet 0,32 s CPU je Lesen, alle anderen nichts. if system_dienst: ns = int(stand["llama-swap"]["CPUUsageNSec"]) + lesungen[art] * 320_000_000 stand["llama-swap"] = {**stand["llama-swap"], "CPUUsageNSec": str(ns)} lesungen[art] += 1 return stand monkeypatch.setattr(dienste, "_systemctl_show", show) return lesungen def test_liste_mit_zustand_und_kennzahlen(box): daten = dienste.liste() zeilen = {z["unit"]: z for z in daten["services"]} assert [z["unit"] for z in daten["services"]] == [ "llama-swap", "mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme"] assert daten["speicher_gesamt"] == 131738611712 and daten["kerne"] == 32 # Was die MCP-Werkzeuge lesen, ist geblieben. assert {"name", "unit", "url", "ok", "scope", "schlaeft"} <= set(zeilen["llama-swap"]) assert daten["links"]["engine_ui"].endswith("/ui") engine = zeilen["llama-swap"] assert engine["zustand"] == "laeuft" and engine["scope"] == "system" assert engine["speicher_bytes"] == 3722850304 and engine["grafik_bytes"] == 27611533312 assert engine["laeuft_seit_s"] == int(JETZT + 0.5 - 74032.986745) assert engine["cpu_prozent"] == 2.0 # erste Abfrage: 0,32 s CPU in der halben Sekunde bis zur zweiten Probe assert zeilen["hermes-gateway"]["cpu_prozent"] == 0.0 and zeilen["hermes-gateway"]["grafik_bytes"] is None # Ohne HTTP-Port entscheidet systemd, ob der Dienst läuft. assert zeilen["mc2-steward"]["ok"] is True and zeilen["lucy-stimme"]["speicher_bytes"] == 7500505088 voice = zeilen["voice-service"] assert voice["schlaeft"] is True and voice["zustand"] == "schlaeft" and voice["ok"] is False assert voice["speicher_bytes"] is None and voice["cpu_prozent"] is None def test_zweite_abfrage_misst_ueber_den_abstand_seit_der_ersten(box): dienste.liste() assert box == {"user": 2, "system": 2} # erste Abfrage: kurze zweite Probe engine = next(z for z in dienste.liste()["services"] if z["unit"] == "llama-swap") assert box == {"user": 3, "system": 3} # jetzt ohne zweite Probe assert engine["cpu_prozent"] == 0.1 # 0,32 s CPU in 10 s auf 32 Kernen def test_liste_ohne_systemd_wie_bisher(box, monkeypatch): """Am PC gibt es kein systemd: die Liste steht, der Zustand kommt allein aus der Erreichbarkeit.""" monkeypatch.setattr(dienste, "_systemctl_show", lambda units, system_dienst: {}) zeilen = {z["unit"]: z for z in dienste.liste()["services"]} assert zeilen["llama-swap"]["zustand"] == "laeuft" and zeilen["llama-swap"]["grafik_bytes"] is None assert zeilen["voice-service"]["zustand"] == "antwortet_nicht" and zeilen["voice-service"]["schlaeft"] is False assert zeilen["mc2-steward"]["ok"] is False assert all(z["speicher_bytes"] is None and z["cpu_prozent"] is None for z in zeilen.values()) def test_gescheiterter_dienst(box, monkeypatch): def show(units, system_dienst): stand = dienste.zuordnen(dienste.lies_show(SYSTEM_AUSGABE if system_dienst else USER_AUSGABE), units) if not system_dienst: stand["hermes-gateway"] = GESCHEITERT return stand monkeypatch.setattr(dienste, "_systemctl_show", show) monkeypatch.setattr(dienste, "agent_status", lambda: { "gateway_reachable": False, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"}) hermes = next(z for z in dienste.liste()["services"] if z["unit"] == "hermes-gateway") assert hermes["zustand"] == "gescheitert" and hermes["schlaeft"] is False assert hermes["neustarts"] == 5 and hermes["aus_seit_s"] is not None and hermes["cpu_prozent"] is None def test_schnittstelle_reicht_die_liste_durch(monkeypatch): from routers import system as system_router monkeypatch.setattr(dienste, "liste", lambda: {"services": [{"unit": "llama-swap", "zustand": "laeuft"}]}) app = FastAPI() app.include_router(system_router.router) assert TestClient(app).get("/api/system/services").json()["services"][0]["zustand"] == "laeuft"