diff --git a/docs/mla-next/ISSUES.md b/docs/mla-next/ISSUES.md index 523761d0..3307e96c 100644 --- a/docs/mla-next/ISSUES.md +++ b/docs/mla-next/ISSUES.md @@ -247,16 +247,16 @@ Schreibfreier Monitor in der GTK-Shell: Werte aus `/proc` (CPU, RAM, PSI u. a.; **Grenzen (gelten für jedes MLA-Next-Paket):** GTK-UI, Flutter-UI und Dart-Host laufen nie als Root; keine generische Root-IPC, keine Shell-Strings, keine Secrets in Logs, Screenshots oder Commits; die vorhandene polkit-Grenze bleibt unberührt. ## Abnahme -- [ ] Je Wert sind Quelle und Einheit dokumentiert; jedes Diagramm zeigt Einheit, Messzeit und Quelle -- [ ] Ringpuffer: Tests für Überlauf, Zeitmonotonie und Lücken (Lücke = `stale`, nie 0) -- [ ] Plausibilisierung gegen den `/proc`-Rohwert innerhalb einer dokumentierten Toleranz — Toleranz legt der Spec fest -- [ ] Tests laufen gegen Fixtures, ohne echtes System +- [x] Je Wert sind Quelle und Einheit dokumentiert; jedes Diagramm zeigt Einheit, Messzeit und Quelle — 2026-10-17: `MONITOR.md` §1 (Tabelle, `VALUE_SPECS` gepinnt) + §4; Caption `Quelle: … · Einheit: … · Messzeit: HH:MM:SS[ · stale: N]` je Diagramm, exakt gepinnt (`FormatterTests`); Sichtprüfung X11 bei `9f13e67` (Ledger), Wayland-Screenshot bleibt manuell +- [x] Ringpuffer: Tests für Überlauf, Zeitmonotonie und Lücken (Lücke = `stale`, nie 0) — 2026-10-17: `RingBufferTests`/`BuildSeriesTests`/`SamplerTests` (Überlauf, strikte Monotonie inkl. NaN/±inf, `None` ≠ `0.0`; `stale`-Formatierung in `FormatterTests`); Kettenraster gegen Zeitgeber-Jitter/Phasendrift (Errata E2/E3, `18a63df`/`e7e8145`) +- [x] Plausibilisierung gegen den `/proc`-Rohwert innerhalb einer dokumentierten Toleranz — Toleranz legt der Spec fest — 2026-10-17: Toleranzen `MONITOR.md` §1; Tests gegen unabhängige Inline-Rechnung (CPU ±0,5 pp, RAM ±0,2 pp, PSI ±0,01); Live-Plausibilität gegen `top`/`free` bleibt manuell (VERIFY-Handoff #96) +- [x] Tests laufen gegen Fixtures, ohne echtes System — 2026-10-17: 7 synthetische `test/fixtures/proc_*.txt`, `FakeReader`/`FakeClock`, `ProcReader` nur gegen tmp-Root; Audit-Hook-Lauf der Suite (77 Tests auf `7f01b7b`, Final-Review-Synthese): 0 Zugriffe auf `/proc`, `/sys`, subprocess, socket (Final-Review-Synthese) ## Handoff (Pflicht je Aufgabe, aus `docs/mla-next/VERIFY.md`) -- [ ] Basis-SHA, Pfade, Scope und Failing-Test/Fixture stehen vor der Umsetzung fest -- [ ] Reviewer 1 (Funktion/UX/Races) und Reviewer 2 (Privilegien/Secrets/argv/IPC) haben geprüft -- [ ] Wirklich ausgeführte Gates mit Ausgabe; rote oder übersprungene Gates benannt; Rückfallplan genannt -- [ ] Kein Merge, Release oder Policy-Update ohne gesonderte Freigabe +- [x] Basis-SHA, Pfade, Scope und Failing-Test/Fixture stehen vor der Umsetzung fest — Basis `de0026d`; SDD-Plan `docs/superpowers/plans/2026-10-02-mla-96-monitor.md` (`5a27740`, vor dem ersten Code-Commit); RED je Task im Abschnitt „Handoff #96" in `docs/mla-next/VERIFY.md` +- [x] Reviewer 1 (Funktion/UX/Races) und Reviewer 2 (Privilegien/Secrets/argv/IPC) haben geprüft — je Task T1–T4 R1+R2 APPROVED (Pin-Commit `7f01b7b` nur Final-Review + Mutationsbelege); Final-Whole-Branch-Review (Opus-Workflow, R1+R2+Vollständigkeits-Kritiker, adversariale Refuter) CHANGES_REQUESTED 0/1/8 → 2 Fix-Runden → Scoped-Re-Reviews **APPROVED** +- [x] Wirklich ausgeführte Gates mit Ausgabe; rote oder übersprungene Gates benannt; Rückfallplan genannt — Gate-Tabellen (Frischlauf vor und nach den Fixes) und Rückfallplan im Handoff-Abschnitt; übersprungen: `build-deb.sh`, Wayland-Screenshot, manuelle Zorin-Checks (benannt) +- [x] Kein Merge, Release oder Policy-Update ohne gesonderte Freigabe — eingehalten: bis zum Handoff-Commit kein Push; Push + PR (ohne Merge) freigegeben von Basti am 2026-10-17, erst nach dem Handoff-Commit; #96 bleibt bis zur Abnahme offen ## Abhängigkeiten Blockiert durch: #91, #93, #94 diff --git a/docs/mla-next/MONITOR.md b/docs/mla-next/MONITOR.md new file mode 100644 index 00000000..a162ea65 --- /dev/null +++ b/docs/mla-next/MONITOR.md @@ -0,0 +1,388 @@ +# MLA-Next Systemmonitor — Werte, Quellen, Semantik (Issue #96, A5) + +> Stand: 2026-10-17 (Task 5, nach Final-Review) · Branch `feature/mla-96-monitor` (T5 via Session-Worktree) · Plan: +> `docs/superpowers/plans/2026-10-02-mla-96-monitor.md` +> +> Spec-Artefakt des lesenden GTK-Systemmonitors (#96). Die maschinenlesbare +> Fassung der Wertespec liegt in `prototype/gtk/monitor_model.py` +> (`VALUE_SPECS`); diese Datei dokumentiert sie und die Regeln dahinter. +> §1–§7 sind vollständig. §1–§4 und §6 hat das Final-Whole-Branch-Review +> (`de0026d..7f01b7b`) gegen Code und Tests kreuzgeprüft, §2–§4 nach den +> Errata E2/E3 die Scoped-Re-Reviews; §5, §7 und die §6-Nachträge entstanden +> im Handoff-Commit (Controller plus Claim-Check). +> +> Der Monitor ist rein lesend: kein Root, keine IPC, keine Admin-Aktionen; +> gelesen werden ausschließlich Dateien unter `/proc`. + +## 1. Werte & Quellen (Wertespec D1) + +Fünf Werte, genau diese; Erweiterungen sind spätere Slices. Die Parser +liefern Rohwerte (0–100 %), die Anzeige-Rundung übernimmt der Formatierer +(Task 4). + +| Wert (id) | Label | Quelle | Formel | Einheit | Anzeige | Toleranz | +|---|---|---|---|---|---|---| +| `cpu_percent` | CPU | `/proc/stat`, Zeile `cpu ` | `100 × Δbusy/Δtotal`, `busy = total − idle − iowait`, Delta zweier aufeinanderfolgender Proben | % | 1 Dezimale | ±0,5 pp | +| `ram_percent` | RAM | `/proc/meminfo` | `100 × (MemTotal − MemAvailable)/MemTotal` | % | 1 Dezimale | ±0,2 pp | +| `psi_cpu_avg10` | PSI CPU | `/proc/pressure/cpu` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | +| `psi_memory_avg10` | PSI Speicher | `/proc/pressure/memory` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | +| `psi_io_avg10` | PSI I/O | `/proc/pressure/io` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | + +### Parser-Regeln (bindend, umgesetzt in Task 1) + +- `parse_cpu_times`: aggregierte `cpu `-Zeile; `total` = Summe der Felder 1–8 + (user, nice, system, idle, iowait, irq, softirq, steal). Die Felder 9/10 + (guest, guest_nice) zählen **nicht zusätzlich** — sie stecken laut + `man proc` bereits in user/nice. `idle` = Feld 4, `iowait` = Feld 5 + (fehlend = 0). Weniger als 4 Felder → `ValueError`. +- `cpu_percent`: `None` bei fehlender Vorgängerprobe oder `Δtotal <= 0` — + die erste CPU-Probe ist damit bewusst eine Lücke (Task 3), kein Fake-Wert. +- `parse_meminfo`: `MemTotal` und `MemAvailable` sind Pflicht → sonst + `ValueError`. +- `meminfo_percent`: `None` bei `total <= 0`. +- `parse_pressure_avg10`: `some`-Zeile und `avg10=` sind Pflicht → sonst + `ValueError`. +- Die Parser **werfen** bei fehlenden Pflichtfeldern `ValueError` mit klarer + Botschaft; Lücken-Handling (stale statt 0) macht ausschließlich der Sampler + (Task 3). Kein `try/except`-Schlucken in den Parsern. + +### Fixture-Anker (synthetische Snapshots, `test/fixtures/proc_*.txt`) + +- `proc_stat_a.txt` → `proc_stat_b.txt`: Δtotal 996, Δbusy 376 → + `100 × 376/996 = 37,7510… %` (Anzeige in Task 4: `37.8 %`). +- `proc_meminfo.txt`: `100 × 7066996/16066996 = 43,98455… %` + (Anzeige: `44.0 %`). +- `proc_pressure_cpu.txt` / `proc_pressure_memory.txt` / + `proc_pressure_io.txt`: `some avg10` 0.52 / 1.25 / 2.50. +- Die Tests (`prototype/gtk/tests/test_monitor_model.py`) rechnen die + Erwartungswerte **unabhängig inline** aus den Fixture-Strings nach; + zusätzlich gilt für alle fünf Werte `0 ≤ wert ≤ 100`. + +### Toleranzbegründung + +Die Toleranzen sind an der Anzeige ausgerichtet, nicht an der +Parser-Präzision: Anzeige-Rundung (halbe letzte Stelle) mit Reserve — +±0,05 bei 1 Dezimale, ±0,005 bei 2 Dezimalen. Bei der CPU kommt die +Jiffy-Granularität der Zähler hinzu (beide Proben zählen in +1/100-s-Schritten; der Auflösungsfehler wirkt als Rundung des Verhältnisses), +daher ±0,5 pp. + +## 2. Ringpuffer & Serien + +### Puffer-Regeln (bindend, umgesetzt in Task 2) + +`RingBuffer(capacity)` hält zeitgestempelte Einträge `Sample(ts, payload)`; +der Payload ist beliebig — der Sampler nutzt in Task 3 `ValueSample(wall, +value)`. Der Puffer ist eine reine Datenstruktur: keine Wertelogik, kein +Parsing, kein I/O. + +- `capacity` ist eine Slot-Anzahl: alles außer `int` ≥ 1 → `ValueError` + (auch `bool`, ganzzahlige Floats wie `3.0`, `nan`, `inf`; PR-Review #119, + Plan-Erratum E4). +- `append(ts, payload) -> bool` akzeptiert nur **streng größere** Zeitstempel + als der letzte Eintrag (strikte Zeitmonotonie). `ts <= letzter ts` wird + abgelehnt: Rückgabe `False`, Zähler `rejected` erhöht, **keine Mutation**. + Der erste Append ist akzeptiert, außer bei `NaN`. +- `NaN`-Zeitstempel werden immer abgelehnt, auch als erster Append — ein NaN + im Puffer ließe jeden späteren Vergleich `ts > nan` scheitern und + blockierte den Wert dauerhaft (PR-Review #119). Der Sampler macht daraus + die Lücke `Zeit nicht monoton` (§3). `+inf`/`-inf` nehmen als geordnete + Werte (größter/kleinster Zeitstempel) am normalen Vergleich teil: `+inf` + wird angenommen, solange der letzte Eintrag kleiner ist; `-inf` wird bei + bestehendem Puffer abgelehnt (nur als erster Append akzeptiert). +- Überlauf: Bei voller Kapazität fällt der älteste Eintrag heraus; die + Reihenfolge bleibt älteste→neueste, `len(ring) <= capacity` gilt immer. +- `entries` liefert eine Tupel-Momentaufnahme älteste→neueste — nie die + interne Liste; `__len__` und `rejected` (Start 0) sind lesbar. + +### Serien-Regeln (bindend, umgesetzt in Task 3) + +`build_series(entries, *, interval, capacity, now)` bildet das feste Raster +über den Puffereinträgen, `stale_count(series)` zählt die Lücken; der Sampler +nutzt als Payload `ValueSample(wall, value)` (Wanduhr + Wert, §3). + +- Das Raster hat **exakt `capacity`** Slots und ist ein **Kettenraster** + (Plan-Erratum E3, das E2 verfeinert): Einträge mit endlichem `ts <= now` + werden nach `ts` absteigend durchlaufen. Der jüngste liegt + `k0 = floor((now − ts)/interval + 0,5)` Slots vor dem letzten Slot (Index + `capacity − 1 − k0`); jeder ältere liegt `step = floor(Δ/interval + 0,5)` + Slots vor dem zuletzt berücksichtigten, nächstjüngeren Eintrag (Δ = dessen + `ts` minus eigenes `ts`; Index = dessen Index − `step`). `step == 0` heißt + derselbe Slot: der jüngere Wert bleibt stehen. Ein Index unter 0 beendet + die Kette — alle älteren Einträge fallen ebenfalls weg, ebenso Einträge in + der Zukunft (`ts > now`). +- **Wann eine Lücke entsteht:** nur bei echtem Abstand ab 1,5 Intervallen + zwischen zwei aufeinanderfolgenden Einträgen (`step >= 2` lässt `step − 1` + Slots leer). Jitter unter `interval/2` und die Phasendrift des Zeitgebers + (§4 taktet mit genau der Slotbreite; jeder Main-Loop-Stall verschiebt die + Phase von `timeout_add_seconds` dauerhaft) verschieben keinen Eintrag: ein + absolutes `floor(ts/interval)`-Raster oder ein nur am letzten Tick + verankertes Raster ließe dagegen schon wenige Dutzend Millisekunden + Verspätung bzw. eine aufsummierte Drift von 1,5 s eine falsche + `stale`-Lücke erzeugen. Grenzfall: viele Abstände knapp unter 1,5 + Intervallen (je ein Slot) können das Fenster zeitlich stauchen — das + Raster zählt Ticks, nicht Wanduhr. +- **Nicht-endliche Zahlen:** Einträge mit nicht-endlichem `ts` (`±inf`, `NaN`) + werden übersprungen, ein nicht-endliches `now` ergibt das leere Raster + (`(None,) * capacity`); `build_series` wirft dafür nie. Der + `RingBuffer`-Vertrag (`±inf`, s. o.) bleibt unverändert. +- Enthält ein Slot mehrere Einträge, gewinnt der **jüngste** (größter `ts`) — + unabhängig von der Eingabereihenfolge. +- Leere Slots sind `None` (stale) und damit strikt unterscheidbar von `0.0`: + eine Lücke wird nie als 0 dargestellt oder interpretiert (D4). +- Rückgabe ist ein Tupel der `payload.value`-Werte älteste→neueste + (links→rechts im Raster); vor dem ersten Tick ist die Serie vollständig + `None` der Länge `capacity` (kein Fehler). +- `stale_count(series)` ist die Anzahl der `None`-Slots. + +## 3. Sampler & Leseregeln + +### Sampler-Regeln (bindend, umgesetzt in Task 3) + +`Sampler(read, clock, interval=3.0, capacity=60)` misst die fünf Werte aus +`VALUE_SPECS` bei jedem `tick()` — bewusst **start/stop-frei**: + +- **Injektion:** `read(relpath)` und `clock()` kommen als Konstruktor- + Parameter. Produktionsuhr: `(time.monotonic(), time.time())`. Der Lese-Pfad + je Wert ist `spec.source` ohne Präfix `/proc/` (z. B. `'stat'`, + `'pressure/cpu'`) — `VALUE_SPECS` bleibt die einzige Pfad-Quelle. +- **Lesen:** `ProcReader(root='/proc')` liest ausschließlich per `open` + + `read` (kein Subprozess, kein Shell); der Produktions-Sampler wird mit + `ProcReader().read` verdrahtet. +- **Zeitbasen:** `clock()` liefert `(mono, wall)`. Ringpuffer-Zeitstempel ist + die **monotone** Uhr (immun gegen Wanduhr-Sprünge/NTP); `ValueSample.wall` + trägt die Wanduhr nur für die Anzeige („Messzeit"). `series`/`stale_count` + nehmen `now` monoton entgegen; `now=None` bedeutet „letzter Tick" (vor dem + ersten Tick: komplett `None`). +- **Per-Wert-Lücken:** Jeder Wert hat sein eigenes `try/except (OSError, + ValueError)`; ein Fehler wird zur Lücke, kein Crash, die übrigen Werte + laufen weiter. Ein Wert mit frischem Sample steht **nicht** in `gaps`. +- **Erster CPU-Tick:** ohne Vorprobe fehlt die Delta-Basis → bewusst Lücke + („Delta-Basis fehlt (erster Tick)"), nie ein Fake-Wert. Die Probe wird + trotzdem gespeichert und nach jedem Tick aktualisiert. +- **Fehlende Dateien:** fehlende `pressure`-Dateien (alte Kernel) nehmen + denselben Lücken-Pfad; kaputtes/trunkiertes `meminfo` wirft `ValueError`. +- **`gaps`:** Dict `value_id → Kurztext`, auf den **letzten Tick** beschränkt + (zu Tick-Beginn geleert). Format `Typ: relpath`, z. B. `OSError: + pressure/io` oder `ValueError: meminfo`; dazu die Sonderfälle „Delta-Basis + fehlt (erster Tick)", „Delta ungueltig", „Wert ungueltig: relpath" und + „Zeit nicht monoton" (abgelehnter Append). Keine Inhalte, keine Secrets. +- **Nicht-endliche Messwerte:** `parse_pressure_avg10` wirft bei `nan`/`inf` + als `avg10` einen `ValueError` — der Sampler macht daraus eine Lücke + (`ValueError: pressure/…`) statt eines `nan %`-Samples. +- **Zugänge für die UI:** `series(value_id, now=None)`, `samples(value_id)` + (älteste→neueste, fürs Drill-down), `latest(value_id)` und + `stale_count(value_id, now=None)`; `interval`/`capacity` sind lesbar. +- **Start/stop-frei:** Den 3-s-Zeitgeber besitzt die UI im GLib-Main-Loop — + Andockpunkt für #97. Sampler, Ringpuffer und Serien laufen + **single-threaded** im Main-Loop; `RingBuffer.append` braucht deshalb keinen + Lock (der kurze Zwischenzustand `len == capacity + 1` innerhalb eines + Appends ist unkritisch). + +## 4. Darstellung & Drill-down + +### Aufbau je Wert (umgesetzt in Task 4) + +Je `VALUE_SPECS`-Eintrag eine Zeile aus Caption-Label und +`Gtk.DrawingArea` (Mindesthöhe 72 px, `hexpand`/`vexpand`); Zeilenabstand +space3, Label↔Diagramm space1 (TOKENS.md §2). Ein `Gtk.GestureClick` je +Zeile (Signal `released`, `value_id` als Signal-User-Data) öffnet das +Drill-down (nur Primärklick, `Gdk.BUTTON_PRIMARY`); die ganze Zeile ist +Klickfläche. + +### Caption (exakt, `format_caption`) + +`Quelle: · Einheit: · Messzeit: HH:MM:SS` + +- Zeit aus `latest(value_id).wall` (Wanduhr, ausschließlich Anzeige — §3) + über `time.localtime`; ohne Messwert steht `–` (Halbgeviertstrich) im + Zeitfeld. +- Das Suffix ` · stale: N` erscheint nur bei `N > 0` + (N = `stale_count(value_id)`). + +### Anzeigewert (exakt, `format_value`) + +`spec.decimals` Dezimalen mit Punkt als Trenner plus Einheit (`37.8 %`, +`0.52 %`); `None` wird exakt `stale` — nie `0.0 %` oder `0 %` (D4). + +### Drill-down (exakt, `format_drilldown`) + +Kopfzeilen `Label: Wert`, `Quelle:`, `Formel:`, `Toleranz: ±{spec.tolerance} pp`, +`stale: N`, danach höchstens die letzten 10 Samples älteste→neueste als +`HH:MM:SS␣␣Wert` (zwei Leerzeichen; Zeit aus `sample.wall`). Ohne Samples +bleiben die Sample-Zeilen weg (der Kopf sagt dann `Label: stale`). Der +Text steht in der rechten Detail-Leiste und trägt dort die farbfreie +Klasse `.mla-drilldown` (Monospace, TOKENS.md §8); der Chip zeigt dabei +`messend` (Tone `running`). + +### Diagramme (Cairo, `MonitorView._draw_chart`) + +- Grundlinie in `border-subtle`, Datenpolyline in `accent` — beide zur + Laufzeit über `StyleContext.lookup_color()` geholt (Fallback: aktuelle + Textfarbe); kein Farbwert im Quelltext (TOKENS.md §8). +- Feste Y-Skala 0–100 %: Grundlinie = 0 %, Oberkante = 100 %; Werte + werden fürs Zeichnen auf [0, 100] geklemmt. +- X-Raster = `sampler.series(value_id)`: Slot `i` liegt bei + `(i + 0,5) × Breite/Kapazität` (älteste links); `None`-Slots brechen + die Linie — es wird nie durch eine Lücke (oder gar 0) gezogen; eine + leere Serie zeigt nur die Grundlinie. +- Ein Einzelwert ohne Nicht-`None`-Nachbarn links und rechts (z. B. der erste + Sample nach dem Start oder ein Wert zwischen zwei Lücken) hätte als Linie + keine Länge und wird deshalb als gefüllter Punkt (Radius 1,5 px) in der + Linienfarbe `accent` gezeichnet. + +### Zeitgeber & Refresh (D6, Single-Source) + +- Der einzige Mess-Zeitgeber lebt in der Shell: + `GLib.timeout_add_seconds(int(sampler.interval), self._tick)`; `_tick` + ruft `sampler.tick()` + `MonitorView.refresh()` und gibt + `GLib.SOURCE_CONTINUE`. Doppelstart-Guard über `_tick_source_id` + (armt nur, solange keine Source-ID steht). +- `MonitorView` besitzt bewusst keinen eigenen Timer; `refresh()` zieht + Captions (auf den letzten Tick) und stößt `queue_draw` für alle + Flächen an. +- `App.do_shutdown` bestellt die Source über `GLib.source_remove` ab + (`Window.stop_ticking`, idempotent). +- MODULES-Untertitel des Monitor-Eintrags exakt `60 × 3 s · /proc`. + +## 5. Vergleichsbasis Flutter + +Gegenstück in der Flutter-App ist `SystemStatsService` +(`lib/services/system_stats_service.dart`); die Werte unten sind dort +zeilengenau belegt (Zeilenangaben ohne Dateinamen = +`system_stats_service.dart`). Daneben hat die Flutter-App den +Systemmonitor `SystemMonitorService` (`lib/services/system_monitor_service.dart`, +Admin-Hub E3): CPU aus `/proc/stat` (Δbusy/Δtotal, `total` inkl. +guest-Felder), RAM aus `/proc/meminfo` mit derselben Formel, 60 × 1 s, +Pause über `TickerMode`, Lücken werden dort 0. Er ist inhaltlich näher, +aber nicht die Vergleichsbasis aus ISSUES #96. Gleich ist nur das Raster — Quellen und +Lückensemantik unterscheiden sich bewusst. + +| Aspekt | Flutter (`SystemStatsService`) | GTK-Monitor (#96) | +|---|---|---| +| Raster | `historyLength = 60`, `defaultInterval = 3 s` (`:85-86`) | `60 × 3 s` (`Sampler`-Default, MODULES-Untertitel) — identisch | +| CPU | 1-Minuten-`loadavg` aus `/proc/loadavg` geteilt durch die Thread-Zahl (`lib/linux/linux_system.dart:55-60`) — eine Last-Kennzahl, keine Auslastung | `100 × Δbusy/Δtotal` aus `/proc/stat` (§1) — Auslastung in % zwischen zwei Ticks; **nicht** direkt mit dem Flutter-Wert vergleichbar | +| RAM | Subprozess `free -m` je Tick (`:187-188`), `usedRatio` | `/proc/meminfo`: `100 × (MemTotal − MemAvailable)/MemTotal`, kein Subprozess | +| PSI | kein Gegenstück | `some avg10` für CPU, Speicher, I/O (§1) | +| Lücken | Ist `free` nicht parsebar, wiederholt die Historie den letzten Wert (`memory ?? previous.memory`) bzw. schreibt **0**, solange noch nie ein Wert vorlag (`:210-213`); bei einer Exception bleibt die alte Historie unverändert stehen (`:216-231`) — in der Historie ist eine Lücke nie als Lücke sichtbar (nur der Exception-Pfad setzt `error`, den das Dashboard als Warnkarte zeigt) | Lücke = `None` → `stale`, nie 0 (§2/§3); Linie bricht (§4) | +| Zeitstempel | ein `updatedAt` je Snapshot (Wanduhr) | Monotonie-Uhr für Puffer/Raster, Wanduhr nur für die Anzeige (§3) | +| Pausieren | pollt nur bei `acquire` + aktiver Sektion + sichtbarem Fenster (`:66-77`, `_sync` `:151-159`) | **noch keine Pause** — der Tick läuft, solange die App lebt → #97 (§7) | +| Prozesse je Tick | mehrere Subprozesse (`free`, `df`, `ps` …) über `Future.wait` (`:183-195`; der Doc-Kommentar `:73-77` spricht von acht) | 0 Subprozesse; nur Lesen von 5 festen `/proc`-Dateien | + +Folgerung für spätere Vergleiche (#95-Baseline, #97): CPU-Kurven beider +Apps nebeneinander zu legen, ist nur nach Umrechnung sinnvoll (gegen `SystemStatsService`; gegen +`SystemMonitorService` sind CPU und RAM direkt vergleichbar, bis auf +Raster 1 s statt 3 s, guest-Felder und Lückensemantik); die +RAM-Kurven sind bis auf die Lückensemantik vergleichbar. + +## 6. Tests & Fixtures + +### Modell-Suite `prototype/gtk/tests/test_monitor_model.py` + +- Tasks 1–3 (Parser, Ringpuffer, Serien, Sampler) sind dort vollständig + gepinnt; Fixture-Anker siehe §1. +- Task 4 ergänzt `FormatterTests` (8 Tests, heute 10) mit exakten Strings; TZ + wird in setUp/tearDown auf `UTC` erzwungen (`TZ` + `time.tzset()`), damit + die `HH:MM:SS`-Felder deterministisch sind; Fix-Runde 1 (`18a63df`) ergänzt einen Test auf + die Nicht-UTC-Zone `XXX-02` und pinnt so die Lokalzeit-Anzeige + (`Messzeit: 02:16:40`), ein weiterer die festen zwei Dezimalen + (`2.50 %`): + - `format_value`: `37.751` → `37.8 %`, `43.98455…` → `44.0 %`, + `0.52` → `0.52 %`; `None` → exakt `stale` (mit + `assertNotEqual`-Pins gegen `0.0 %`/`0 %`). + - `format_caption`: exakt + `Quelle: /proc/stat · Einheit: % · Messzeit: 00:16:40`; mit Lücken + `… · stale: 57`, ohne Messwert `… · Messzeit: – · stale: 60`. + - `format_drilldown`: exakter Mehrzeiler aus Kopf und letzten 10 + Samples; ohne Samples exakt 5 Zeilen (`CPU: stale` als erste); bei + 12 Samples nur die letzten 10 (`00:16:46 …` bis `00:17:13 …`). + +- Der in T5 übernommene Pin-Commit `7f01b7b` (Fremd-Diff, Mutationsbelege + in T5) pinnt den ±inf-Monotonievertrag (§2), + den stat-Lesefehler ohne gespeicherte Delta-Basis, den + truncated-stat-`ValueError` und den Durchgriff von `interval`/`capacity` + ins Serienraster — je mit Mutationsbeleg. +- Die Final-Review-Fixes (`18a63df`, `e7e8145`) pinnen Jitter und + Zeitgeber-Phasendrift (Kettenraster, §2; je ein `build_series`- und ein + Sampler-Test), eine echte Lücke ab 1,5 Intervallen, die Fenstergrenze, + Einträge in der Zukunft (`ts > now`), nicht-endliche `ts`/`now`/`avg10` und den meminfo-Leerwert. + +### Token-Suite `prototype/gtk/tests/test_tokens.py` + +- Das Hardcode-Gate prüft seit #96 alle drei Shell-Dateien + (`SCANNED_PY`: `mla_app.py`, `monitor_ui.py`, `monitor_model.py`); + der Known-Bad-Nachweis (`#B8860B`, `rgba(`) bleibt als RED-Beleg + bestehen. Die Erweiterung lief zunächst rot (`monitor_ui.py` fehlte + noch, `FileNotFoundError`), danach grün. +- Konsistenz-/Kontrast-Tests (§1, §5) bleiben unverändert. + +### Ausführung (Gates) + +- GTK: `python3 -m unittest discover -s prototype/gtk/tests` (aus dem + Repo-Root) — 96 Tests (84 Modell + 10 Token + 2 Shell-Layout; davon 4 + Modell-Tests aus dem PR-Review #119: NaN als Erst-Zeitstempel, ungültige + Kapazitäten). Die Layout-Klasse in `test_shell_layout.py` braucht GTK + 4/libadwaita und ein Display und wird ohne beides — so in CI — + übersprungen. +- Bestehende Python-Suite: `cd additional/python && python3 -m unittest + discover -s tests -t .` — 53 Tests, unabhängig vom GTK-Track. +- Start-Gates (BASELINE §2): Start unter Wayland und X11 je + `timeout 6` → Exit 124 (App läuft, kein stderr); Screenshot nur als + /tmp-Artefakt, nie im Repo (TOKENS.md §7). + +## 7. Grenzen / #97 + +Bewusst **nicht** in #96 (Scope-Grenze laut Plan „Nicht-Ziele"): + +- **Hintergrund-Pause, Langlauf, Accessibility** → #97. Der + `GLib`-Tick läuft auch bei unsichtbarer Monitor-Ansicht oder + minimiertem Fenster weiter; Diagramme haben noch keine + Textalternativen; Fokusreihenfolge und Kontrast der Chart-Flächen sind + ungeprüft. #97 braucht hierfür einen Test oder Trace „unsichtbar ⇒ kein + Tick" (Abnahme 1 dort). +- **Gequetschte rechte Detail-Leiste** (vorbestehend seit dem Scaffold + `6c5c625`, nicht #96-verursacht) — **triagiert und behoben** auf + `fix/mla-details-pane-width`. Root Cause: `Gtk.Paned` liegt im + Inhaltsbereich der NavigationSplitView (1200 px Fenster − 280 px + Nav-Sidebar = 920 px), `PANE_POSITION` 850 zählt ab dessen linker Kante; + mit dem GTK4-Default `shrink-end-child=TRUE` ignoriert der Trenner die + 280-px-Mindestbreite. GTK alloziert die Leiste trotzdem mit 280 px, aber + ab Fenster-x 1131 — 211 px liegen jenseits des rechten Rands und werden + abgeschnitten (sichtbar 69 px unter X11; unter Wayland skaliert der + Paned 850 nach dem Compositor-Resize auf 954 px proportional auf 661 → + 54 px). Fix: `shrink-end-child=False` in `build_detail_pane`; der + Trenner wird jetzt auf `Paned-Breite − 1 − 280` geklemmt (bei 1200 px: + 639, Leiste 280 px bis exakt zur Fensterkante). Regression: + `test_shell_layout.py` (RED 69/54/51/100 px sichtbar → GREEN). +- **Start-Gate-Teardown-Flake**: in T4 einmal 116 B + `GLib-GIO-CRITICAL … check_unclosed` beim SIGTERM-Teardown unter + Wayland (1 von 4 Läufen), Exit stets 124; im T5-Frischlauf 0 von 6 (3 Wayland + 3 X11). + In den Final-Fix-Runden etwa 1 von 5 Läufen (115 B), auch auf dem + unveränderten Vor-Fix-Stand `4e61a80` (1 von 8); Gate-Frischlauf nach den Fixes 0 von 6. + Gilt als umgebungsbedingt und vorbestehend; Root Cause in #97 + (Langlauf/Shutdown-Pfad). +- **Methodik-Hinweise für UI-Belege** (aus T4): vor `xdotool`-Klicks das + mutter-Guard-Fenster unmappen; teil-offscreen Fenster stoppen den + Ticker-Repaint; PNG-`md5` taugt nicht als Pixelvergleich (tIME-Chunk) — + `compare -metric AE` verwenden. +- **Geparkter Test-Pin**: Der `%r`-Wortlaut der Kapazitätsmeldung + (`RingBuffer`) ist nicht wortgleich gepinnt (T2-N2), nur der + `ValueError`-Typ. +- **Getrennte Ketten je Wert** (Final-Review N3, geparkt): Jeder Wert bildet + sein Kettenraster (§2) für sich. Fällt bei einem Wert ein Tick durch einen + Lesefehler aus, während benachbarte Abstände durch Zeitgeber-Stalls + gedehnt sind, kann dieser Wert eine Lücke zu viel zeigen und seine ältere + Historie um einen Slot gegen die anderen Diagramme versetzen — stets in + die vorsichtige Richtung (mehr `stale`, nie verdeckt). Kandidat für #97: + eine gemeinsame Tick-Kette im Sampler, in die jeder Wert seine Lücke + einträgt. +- **Suspend/Uhrensprung:** Puffer und Raster laufen auf `time.monotonic()` + (= `CLOCK_MONOTONIC`, ohne Suspend-Zeit, §3). Nach einem Suspend schließt + das Raster die Werte vor dem Suspend ohne Lücke an die frischen an — der + Messausfall ist im Diagramm **nicht** als `stale` sichtbar, nur am Sprung + der Wanduhr in Caption-`Messzeit` und Drill-down-Zeilen. Langlauf und + Suspend-Verhalten (z. B. `CLOCK_BOOTTIME`) prüft #97. +- **Weitere Werte** (Temperaturen, Netz, Datenträger) sind spätere + Slices; §1 legt genau fünf Werte fest. +- **Keine IPC, kein Root, keine Schreibzugriffe** — der Monitor bleibt bis + A4 (#100) ein reiner Datei-Leser in der unprivilegierten Shell. diff --git a/docs/mla-next/TOKENS.md b/docs/mla-next/TOKENS.md index d965276c..d684bc6e 100644 --- a/docs/mla-next/TOKENS.md +++ b/docs/mla-next/TOKENS.md @@ -65,7 +65,7 @@ Schemata dunkle Tinte. Elevation entsteht aus 1px-Border, nicht aus Schatten. | opacityFaint / opacityMuted / opacityStrong | 0.42 / 0.56 / 0.75 | Entwertung von Metadaten über Opacity, nie über andere Farbe | | fontMono | `monospace` | generischer Alias (kein gebundelter Font) | | layoutSidebarMin | 280 | Mindestbreite rechte Detail-Leiste | -| layoutPanePos | 850 | Startposition des Trenners (Gtk.Paned) | +| layoutPanePos | 850 | Startposition des Trenners (Gtk.Paned), ab dessen linker Kante (rechts der Nav-Sidebar); auf `Paned-Breite − 1 − layoutSidebarMin` geklemmt (`shrink-end-child=False`) | | layoutWindow | 1200 × 780 | Fenster-Defaultgröße | ## 3. Typografie (Adw-Klassen als Typo-Tokens) @@ -156,10 +156,17 @@ kein `@media` in provider-geladenem CSS (Parser: «Unknown @ rule», verifiziert CSS-Klassen mit Präfix `mla-` — semantische Klassen, deren Padding-/Border-Werte die Struktur-Tokens mit px-Kommentar anwenden (z. B. `.mla-nav-label` = 12/16 px = space3/space4; keine generischen `.mla-space-*`-Utility-Klassen): `.mla-screen` (Seitenrahmen), `.mla-details` (rechte Leiste), `.mla-chip` + `.mla-tone-ok` … `.mla-tone-stale` (Status; stale mit -gestrichelter Border), Fokus-Outline global über `:focus-visible`. Für -Abstände gelten padding/margin-Klassen; `Gtk.Box`-`spacing`, Paned-Position -und Fenstergröße sind dokumentierte Tokens (§2), die im Code Anwendung finden -(GTK bietet dafür kein CSS-Äquivalent). Keine Hex-Farben außerhalb der -Token-Dateien — durchgesetzt per Regex-Gate in `test_tokens.py` -(vormaliger Verstoß `#b8860b` in `mla_app.py:84` ist mit diesem Paket -beseitigt). +gestrichelter Border), `.mla-drilldown` (Monitor-Drill-down, `fontMono` +aus §2, farbfrei — Textfarbe bleibt `@text`), Fokus-Outline global über +`:focus-visible`. Für Abstände gelten padding/margin-Klassen; +`Gtk.Box`-`spacing`, Paned-Position und Fenstergröße sind dokumentierte +Tokens (§2), die im Code Anwendung finden (GTK bietet dafür kein +CSS-Äquivalent). Die Monitor-Diagramme (#96) zeichnen mit Cairo und +beziehen ihre Farben ausschließlich zur Laufzeit über +`StyleContext.lookup_color()` (`border-subtle` = Grundlinie, `accent` = +Polyline; Fallback: aktuelle Textfarbe) — kein Farbwert im Quelltext. +Keine Hex-Farben außerhalb der Token-Dateien — durchgesetzt per +Regex-Gate in `test_tokens.py`, seit #96 über alle drei Shell-Dateien +`mla_app.py`, `monitor_ui.py` und `monitor_model.py` (`SCANNED_PY`); +der vormalige Verstoß `#b8860b` in `mla_app.py:84` war bereits mit dem +#91-Paket beseitigt. diff --git a/docs/mla-next/VERIFY.md b/docs/mla-next/VERIFY.md index eff4285c..987336c4 100644 --- a/docs/mla-next/VERIFY.md +++ b/docs/mla-next/VERIFY.md @@ -541,6 +541,69 @@ unabhängig reproduziert). **Grenzen (bewusst offen).** README:137 `:19`-Ref (Kommentarzeile statt :20 REPO_ROOT-Zuweisung) — vorbestehend, außerhalb B-Buchstabe (nur linux_system.dart-Refs), als Minor bei Reviewer B notiert. `No such file or directory`-Pin ist CI-Linux-garantiert (Dart lokalisiert errno nicht, Runner hardcodet `""`+`e.message`). Der 23:30-Schwesternlauf (Automation) arbeitet mit derselben Queue; #110-B gilt über die Handoff-Sektion als abgeschlossen. +## Handoff #96 — A5 Lesender Monitor (GTK-Shell) + +**Status:** Umgesetzt auf `feature/mla-96-monitor` (T0–T4 bis `1f94619`; T5-Commits ab `7f01b7b` in der Session-Worktree auf `YunoCoder/workflow-mitarbeit-f91bdf`, Push als `HEAD:feature/mla-96-monitor`; Basis-SHA `de0026d` = `main` = `origin/main`), SDD-Plan `docs/superpowers/plans/2026-10-02-mla-96-monitor.md` (Commit `5a27740`, Erratum E1 `4a743b7`). Task-Commits: T1 `b5ce683` + `39d9765` · T2 `d9f56ae` + `28697cb` · T3 `163fe42` + `28a0b3f` · T4 `9f13e67` + `4486664` + `1f94619` · Pin-Tests `7f01b7b` · Final-Review: Erratum E2 `4e61a80`, Fix 1 `18a63df`, Erratum E3 `f0802b3`, Fix 2 `e7e8145` · dieser Abschnitt (Handoff-Commit `6f60352`) · nach dem Copilot-Review von PR #119: Erratum E4 `8af55b6`, Fix 3 `2391512`, VERIFY-Nachtrag (Commit nach `2391512`). Spec: `docs/mla-next/MONITOR.md` (§1–§7). Umsetzung und lokale Commits freigegeben von Basti am 2026-10-02; Push/PR freigegeben am 2026-10-17 (Bedingung APPROVED-Verdikt, erfüllt), **kein Merge, kein Release, kein Policy-Update**. GitHub-#96 bleibt bis zur Abnahme offen. + +**Scope (fest vor der Umsetzung, Plan Tasks 1–5).** Pfade: neu `prototype/gtk/monitor_model.py` (gi-frei), `prototype/gtk/monitor_ui.py`, `prototype/gtk/tests/test_monitor_model.py`, 7 synthetische Fixtures `test/fixtures/proc_*.txt`, `docs/mla-next/MONITOR.md`; geändert `prototype/gtk/mla_app.py`, `prototype/gtk/tokens.css` (farbfreie Klasse), `prototype/gtk/tests/test_tokens.py`, `test/fixtures/README.md`, `docs/mla-next/TOKENS.md` §8, `docs/mla-next/{VERIFY,ISSUES}.md`; in T5 nachgezogen: `prototype/gtk/README.md` (Ledger T4-Concern 2). Unberührt: Flutter-App, `packages/la_core`, `additional/python`, `build.yml`, Packaging, polkit-Trinität. + +**Failing-Test/Fixture (RED je Task, Ledger-belegt).** +- T1 Parser/Wertespec: `ModuleNotFoundError: monitor_model` vor dem Modul; Fixtures `proc_stat_a/_b`, `proc_meminfo`, `proc_pressure_{cpu,memory,io}`, `proc_stat_truncated` pinnen exakt die Testwerte (37,7510 / 43,98455… / 0,52 / 1,25 / 2,50). Mutationsproben: `sum(fields[:7])` → rot (28≠36), Spec-`source`-Drift → rot. +- T2 Ringpuffer: 10× `AttributeError` (RingBuffer/Sample fehlen), danach NaN-Guard test-first (alter Guard → genau NaN-Test rot). +- T3 Serien/Sampler: 20× `AttributeError` (Ran 63); Pin `'Wert ungueltig: meminfo'` mit Mutationsbeleg. +- T4 UI: 8× `AttributeError` (Formatierer) + `FileNotFoundError monitor_ui.py` im erweiterten Hardcode-Gate; Fix-Runde 2: `PyGIWarning` (283 B stderr) beim isolierten Import → nach Gdk-Pin 0 B. +- Pin-Commit `7f01b7b` (Fremd-Diff, mtime 05.10. 23:30 = Automationsslot, Herkunft unbelegt; per Basti-Entscheid 2026-10-17 übernommen): 5 Tests, je ein gezielter Mutant rot (M1/M2/M3/M5 exklusiv, M4 zusammen mit `test_broken_meminfo_is_a_value_error_gap`), Rücknahmen byte-identisch. +- Final-Fix 1 `18a63df`: Jitter-Test auf altem Raster rot (`(None, None, 1.0, None, 3.0)` statt lückenlos), `build_series` mit `+inf` → `OverflowError`, `avg10=nan` ohne Fehler; Pins Lokalzeit/Dezimalen/meminfo-Leerwert je per Mutant rot. +- Final-Fix 2 `e7e8145`: Phasendrift-Folge (Abstände 3,3,4,3,3,4,3,3) auf E2-Raster rot (innere `None`); Fenstergrenzen-Pin per Mutant `index < -1` rot. + +**Kernresultate.** +1. Fünf Werte mit Quelle, Formel, Einheit, Anzeige-Dezimalen und Toleranz (MONITOR.md §1, `VALUE_SPECS`): CPU (`/proc/stat`, Δbusy/Δtotal), RAM (`/proc/meminfo`, MemAvailable), PSI CPU/Speicher/I/O (`some avg10`). +2. Ringpuffer fester Kapazität (nur `int` ≥ 1, sonst `ValueError`): Überlauf verwirft den ältesten Eintrag; Zeitmonotonie streng (`not ts > letzter`; NaN immer abgelehnt, auch als Erstappend — PR-Review #119/Erratum E4; ±inf geordnet); `rejected` zählt nur Zeitstempel-Ablehnungen (Monotonie, NaN); `entries` als Tupel-Snapshot. +3. Serien im Raster 60 × 3 s auf monotoner Uhr als **Kettenraster** (Errata E2/E3: Slots aus den Abständen benachbarter Einträge, Lücke nur bei echtem Abstand ≥ 1,5 Intervallen — Zeitgeber-Jitter und -Phasendrift erzeugen keine falschen Lücken); Lücke = `None` → `stale`, **nie 0**; erster CPU-Tick ohne Delta-Basis ist eine Lücke; Lese-/Parsefehler werden je Quelle zur Lücke mit Grund (`'%s: %s'`). +4. GTK-Ansicht: je Wert Cairo-Diagramm (Linienbruch an Lücken, Einzelwerte als Punkt, Farben per `lookup_color` aus Tokens, Fallback Textfarbe, kein Hex/rgba) + Caption exakt `Quelle: … · Einheit: … · Messzeit: HH:MM:SS[ · stale: N]`; Drill-down nur auf Primärklick in die rechte Detail-Leiste (Formel, Toleranz, stale-Anzahl, letzte 10 Proben); ein einziger `GLib`-Zeitgeber mit Doppelstart-Guard, Abbestellung in `do_shutdown`. +5. Kein Subprozess, kein Socket, kein Root: `ProcReader` liest nur per `open`+`read`, der Sampler fragt ausschließlich die fünf `VALUE_SPECS`-Pfade unter `/proc` ab; Hardcode-Gate (kein Hex/rgba) über alle drei Shell-Dateien. + +**Gates — tatsächlich ausgeführt (2026-10-17, Frischlauf auf `7f01b7b`; Logs gitignored unter `.superpowers/sdd/2026-10-02-mla-96-monitor/gates/`):** + +| Gate | Kommando | Ausgabe | +|---|---|---| +| GTK-Syntax | `python3 -m py_compile prototype/gtk/mla_app.py prototype/gtk/monitor_model.py prototype/gtk/monitor_ui.py` | Exit 0 | +| GTK-Tests | `python3 -m unittest discover -s prototype/gtk/tests -v` | `Ran 77 tests` / `OK` | +| Python-Suite | `cd additional/python && python3 -m unittest discover -s tests -t .` | `Ran 53 tests` / `OK` | +| Flutter-Root | `dart format --output=none --set-exit-if-changed lib test && flutter analyze && flutter test` | `Formatted 123 files (0 changed)` · `No issues found!` · `+208: All tests passed!` | +| la_core | `cd packages/la_core && dart pub get && dart format … lib test && dart analyze && dart test` | `Formatted 24 files (0 changed)` · `No issues found!` · `+68: All tests passed!` | +| Versionen | `bash tool/check-versions.sh` | `version 0.8.0 is consistent` | +| Start-Gate Wayland ×3 | `GDK_BACKEND=wayland timeout 6 python3 prototype/gtk/mla_app.py` | je Exit 124, stdout/stderr 0 B | +| Start-Gate X11 ×3 | `DISPLAY=:1 GDK_BACKEND=x11 timeout 6 python3 prototype/gtk/mla_app.py` | je Exit 124, stdout/stderr 0 B | + +**Gates nach den Final-Review-Fixes (2026-10-17, Frischlauf auf `e7e8145`; Logs `…/gates-postfix/`):** GTK-Syntax Exit 0 · GTK-Tests `Ran 90 tests` / `OK` (80 Modell + 10 Token) · Python-Suite `Ran 53 tests` / `OK` · Flutter-Root `Formatted 123 files (0 changed)` · `No issues found!` · `+208: All tests passed!` · la_core `Formatted 24 files (0 changed)` · `No issues found!` · `+68: All tests passed!` · `version 0.8.0 is consistent` · Start-Gates Wayland ×3 + X11 ×3 je Exit 124, stdout/stderr 0 B. + +**Rote/übersprungene Gates.** Rot im Frischlauf: keine. Im Verlauf rot (alle aufgeklärt, Ledger): Teardown-Flake beim SIGTERM (`GLib-GIO-CRITICAL … check_unclosed`, 115–116 B stderr, Exit stets 124): T4 1 von 4 Wayland-Läufen, in den Final-Fix-Runden etwa 1 von 5 Läufen beider Backends, auf dem unveränderten Vor-Fix-Stand `4e61a80` 1 von 8; beide Frischläufe 0 von 6 — vorbestehend/umgebungsbedingt, Root Cause in #97 (MONITOR.md §7). Übersprungen: `build-deb.sh` (kein Paketbezug; CI baut beim PR), Wayland-Screenshot (bleibt manuell, BASELINE §6.2); X11-Screenshots aus T4 waren `/tmp`-Artefakte (nie im Repo, TOKENS.md §7) und sind gelöscht; die Sichtprüfung ist nur im Ledger belegt (`9f13e67` visuell, `4486664` Klicknachweis); nach den Final-Review-Fixes keine erneute Sichtprüfung. + +**Manuelle Zorin-Prüfung (Basti, offen).** Monitor am lebenden System: Plausibilität der fünf Kurven gegen `top`/`free`/`cat /proc/pressure/*` innerhalb der §1-Toleranzen; Drill-down per Primärklick; Einzelpunkt-Darstellung (r = 1,5 px, nur per pycairo-Sonde belegt); Hell/Dunkel; Wayland-Screenshot. Die vorbestehende gequetschte rechte Detail-Leiste ist behoben (`3e920a8`, MONITOR.md §7) — mitprüfen: Leiste 280 px breit bis zur Fensterkante, Drill-down ungeclippt. + +**Reviewer.** Je Task R1 (Funktion/UX/Races) + R2 (Privilegien/Secrets/argv/IPC) parallel, Fix-Runden bis beide APPROVED (Ledger): + +| Task | R1 | R2 | Fix-Runden | +|---|---|---|---| +| T1 Parser/Fixtures | APPROVED 0/0/3 → alle ADDRESSED | APPROVED 0/0/0 | 1 (`39d9765`) | +| T2 Ringpuffer | APPROVED 0/0/4 → ADDRESSED (+2 Minors: N1 → T3, N2 geparkt) | APPROVED 0/0/2 → ADDRESSED | 1 (`28697cb`) | +| T3 Serien/Sampler | APPROVED 0/0/2 → ADDRESSED | APPROVED 0/0/0 | 1 (`28a0b3f`) | +| T4 UI | APPROVED 0/1/1 (Important vorbestehend → geparkt; Minor gefixt) + N1 gefixt | APPROVED 0/0/0 | 2 (`4486664`, `1f94619`) | + +Der Pin-Commit `7f01b7b` hatte kein eigenes Task-Review; geprüft nur über die Mutationsbelege (`pin-mutations.md`) und das Final-Whole-Branch-Review. + +**Final-Whole-Branch-Review (2026-10-17, `de0026d..7f01b7b`, 12 Commits).** Workflow mit 25 Opus-Agenten: R1 (Funktion/UX/Races) **CHANGES_REQUESTED** 0/1/8, R2 (Privilegien/Secrets/argv/IPC) **APPROVED**, Vollständigkeits-Kritiker **APPROVED**; 20 Roh-Findings, je adversarial verifiziert (critical/important 2 Refuter, minor 1) → 11 überlebend, 9 begründet widerlegt; Synthese **CHANGES_REQUESTED 0 critical / 1 important / 8 minor**. Abnahme 1–4 bestätigt (u. a. Audit-Hook-Lauf: Suite ohne `/proc`-/`/sys`-/subprocess-/socket-Zugriff). +- **S1 (important):** `timeout_add_seconds(3)` taktet mit genau der Slotbreite des absoluten Rasters `floor(ts/interval)` — schon ~60 ms Main-Loop-Verspätung erzeugten eine falsche `stale`-Lücke (im echten GLib-Loop reproduziert). Behoben über Erratum E2 + `18a63df` (Raster am letzten Tick); das Scoped-Re-Review fand den Rest **N1** (Stalls ≥ 0,25 s verschieben die Timer-Phase dauerhaft, ab 1,5 s Drift wieder eine falsche Lücke) → Erratum E3 + `e7e8145` (Kettenraster). Beleg: Simulation 400 Sitzungen je Szenario (Jitter bis 240 ms, Stalls 0,3–1,3 s) 0/400 falsche Lücken (vorher bis 400/400), echter GLib-Loop 0; 300 × 150 Ticks mit 10 % Lesefehlern: 0 verschluckte echte Lücken. +- **Minors behoben** (`18a63df`): Einzelwerte ohne Nachbarn waren unsichtbar (S2), nicht-endliche `ts`/`now`/`avg10` (S3), Pins Lokalzeit (S4), feste Dezimalen (S5), meminfo-Leerwert (S6), §-Verweis/Docstrings (S8); N2 Fenstergrenzen-Pin (`e7e8145`). Im Handoff-Commit: Testzahl in MONITOR §6 (S7), `tokens.css` im Rückfallplan (S9). +- **Scoped-Re-Reviews:** Runde 1 **APPROVED** (S1–S6/S8 ADDRESSED, neu N1/N2 minor), Runde 2 **APPROVED** (N1/N2 ADDRESSED; Mutanten selbst nachgefahren). **Geparkt:** N3 (getrennte Ketten je Wert → bei Lesefehler plus Stall eine Lücke zu viel, nie verdeckend; MONITOR.md §7/#97). +- **Gesamtverdikt: APPROVED** nach 2 von 3 Fix-Runden. Die Commits der Fix-Runden tragen die Attribution des Implementers (`Claude Sonnet 5.5`), die Controller-Commits `Claude Opus 5.5`. +- **PR-Review #119 (Copilot, nach dem Push).** Zwei Punkte, beide behoben über Erratum E4 `8af55b6` + Fix 3 `2391512`: (a) ein NaN als **erster** Zeitstempel landete im Puffer (Leerpuffer-Kurzschluss) und blockierte danach jeden Append (`ts > nan` immer falsch) — jetzt vorab per `math.isnan` abgelehnt, der Sampler meldet wie bisher die Lücke `Zeit nicht monoton` und misst ab dem nächsten gültigen Tick normal; (b) `RingBuffer(capacity)` prüfte nur `< 1` — Brüche, `3.0`, `bool`, nan/inf liefen durch (inf/nan ohne Überlauf, `2.5` erst in `build_series` als `TypeError`), jetzt nur `int` ≥ 1. ±inf-Vertrag unverändert. RED zuerst: 4 neue Tests, `FAILED (failures=7, errors=1)`; danach GTK `Ran 94 tests` / `OK` (84 Modell + 10 Token), Python-Suite `Ran 53 tests` / `OK`, `version 0.8.0 is consistent`, Start-Gates Wayland ×3 + X11 ×3 je Exit 124 (1 von 6 mit dem bekannten Teardown-Flake, 114 B stderr; Logs `…/pr-review-fix/`). Adversariale Prüfung (3 Opus-Linsen: Korrektheit, Mutation, Doku): je **APPROVED**; alle Mutanten getötet (nur ein semantisch äquivalenter überlebt); die 4 Doku-Minors (Testname, Testkommentar zu `bool`, fehlendes Erratum, dieser Nachtrag samt Rückfallplan-Bereich) sind eingearbeitet. Flutter/la_core nicht erneut gelaufen (kein Dart-Bezug). + +**Rückfallplan.** `git revert` der #96-Commits (`5a27740` … VERIFY-Nachtrag nach PR-Review #119, also bis zum Branch-Ende — Probe im Wegwerf-Klon: Exit 0, Baum = `de0026d`; ein Bereich nur bis `6f60352` konfligiert in MONITOR.md und VERIFY.md) genügt: neue Dateien (`monitor_model.py`, `monitor_ui.py`, Testdatei, 7 Fixtures, MONITOR.md, Plan) plus kleine Änderungen (`mla_app.py`, `tokens.css` (farbfreie Klasse `.mla-drilldown`), `test_tokens.py`, `test/fixtures/README.md`, `prototype/gtk/README.md`, TOKENS.md §8, VERIFY/ISSUES). Kein Datenpfad, keine Unit, kein Packaging, keine Migration; polkit-Trinität unberührt. + +**Grenzen (bewusst offen) → #97.** Keine Hintergrund-Pause, kein Langlauf-Nachweis, keine Accessibility (Textalternativen, Fokus, Kontrast der Diagramme); getrennte Ketten je Wert (N3); Suspend-Verhalten; Details MONITOR.md §7. Die vorbestehende gequetschte Detail-Leiste ist vor #97 triagiert und im PR behoben (`3e920a8`: `shrink-end-child=False`, Regression `test_shell_layout.py`); für den Visual-/A11y-Durchgang offen bleiben nur das fehlende `padding-right` der Leiste (Text und Chip laufen bis an die Fensterkante) und die Breitenwahl (`PANE_POSITION` 850 wird bei 1200 px auf 639 geklemmt, die Leiste ist also genau 280 px). Commits dieser T5-Session tragen statt `Claude Code` die Attribution `Claude Opus 5.5` (Controller: `7f01b7b`, `4e61a80`, `f0802b3`, Handoff, `8af55b6`, `2391512`, VERIFY-Nachtrag) bzw. `Claude Sonnet 5.5` (Fix-Implementer: `18a63df`, `e7e8145`) — Plan-C2-Abweichung, bewusst, Ledger. + ## Agenten-Handoff Je Aufgabe: Basis-SHA, Pfade, Scope, Failing-Test/Fixture, Umsetzung, Ergebnis von Reviewer 1 (Funktion/UX) und Reviewer 2 (Sicherheit), **wirklich ausgeführte** Gates mit Ausgaben, rote/übersprungene Gates, manuelle Zorin-Prüfung, Rückfallplan. Kein Merge/Release/Policy-Update ohne gesonderte Freigabe. diff --git a/docs/superpowers/plans/2026-10-02-mla-96-monitor.md b/docs/superpowers/plans/2026-10-02-mla-96-monitor.md new file mode 100644 index 00000000..7a55ad86 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-mla-96-monitor.md @@ -0,0 +1,198 @@ +# SDD-Plan #96 — Lesender Monitor: /proc-Quellen, Ringpuffer, Drill-down + +**Datum:** 2026-10-02 +**Issue:** #96 — `[Next 0.1.x] A5 Lesender Monitor: /proc-Quellen, Ringpuffer, Drill-down` +(`track:mla-next`, `size:L`, `gate:test`; Abnahme 1–4, Handoff-Boxen 1–4 aus `docs/mla-next/ISSUES.md:237-267`) +**Branch:** `feature/mla-96-monitor` von `main` — Basis-SHA `de0026d` +**Freigabe:** Basti 2026-10-02 („push und starte #96"): Umsetzung und lokale Commits freigegeben. +**Kein Push, kein Merge, kein Release, kein Policy-Update** des Branches ohne gesonderte Freigabe. +**Quellen:** `docs/mla-next/ISSUES.md` (#96, #97, #91, #92), `docs/mla-next/AGENT_PLAN.md` (A5, Arbeitspaket-Regeln), +`docs/mla-next/TOKENS.md`, `docs/mla-next/VERIFY.md` (Handoff-Format), `lib/services/system_stats_service.dart:85-86` +(Vergleichsbasis 60 × 3 s), `test/fixtures/README.md` (#94-Lese-Konvention), `additional/python/tests/test_fixture_leak_check.py`, +`prototype/gtk/tests/test_tokens.py`, Pläne `2026-09-30-mla-94-fixtures-state-models.md` / `-95-baseline-flutter-vs-gtk.md` (Format). + +## Global Constraints + +1. **Gates** (aus Repo-Root; Teilgates je Task, alle frisch in Task 5): + + | Gate | Kommando | + |---|---| + | GTK-Syntax | `python3 -m py_compile prototype/gtk/mla_app.py prototype/gtk/monitor_model.py prototype/gtk/monitor_ui.py` | + | GTK-Tests (CI-Gate) | `python3 -m unittest discover -s prototype/gtk/tests` | + | Python-Suite | `cd additional/python && python3 -m unittest discover -s tests -t .` | + | Flutter-Root | `dart format --output=none --set-exit-if-changed lib test && flutter analyze && flutter test` | + | la_core | `cd packages/la_core && dart pub get && dart format --output=none --set-exit-if-changed lib test && dart analyze && dart test` | + | Versionen | `bash tool/check-versions.sh` | + +2. **Commits:** Conventional Commits, deutscher Body, Suffix `(#96)`, exakte Botschaften unten; vor jedem Commit `git status --short`; nur die eigenen Task-Dateien explizit `git add`en (nie `-A`); `.superpowers/`, `build/`, `__pycache__/`, `.pyc` nie committen. Jede Commit-Botschaft endet mit + `Co-Authored-By: Claude Code `. +3. **TDD-Pflicht:** Erst der Test (bzw. das Fixture + Test), der fehlschlägt — **beobachtetes RED** im Report belegen — dann implementieren bis GREEN. Kein Code vor seinem Test. +4. **Grenzen (AGENT_PLAN, für jede Datei):** GTK-UI, Flutter-UI und Dart-Host laufen nie als Root; keine generische Root-IPC, keine Shell-Strings, kein Subprozess (der Monitor liest ausschließlich Dateien — kein `argv`-Pfad entsteht); keine Secrets in Logs, Screenshots oder Commits; die polkit-Grenze bleibt unberührt. Tests importieren **kein `gi`** (CI läuft headless): `monitor_model.py` ist gi-frei. +5. **Kein Hex/rgba in Python** der GTK-Shell: keine Farb-Literale in `mla_app.py`, `monitor_ui.py`, `monitor_model.py`; das Hardcode-Gate in `test_tokens.py` wird in Task 4 auf alle drei erstreckt. Vorsicht auch in Kommentaren: kein `#` + ≥3 Hex-Zeichen (das Regex-Gate würde anschlagen). +6. **Keine neuen `@define-color`**: `test_tokens.py::test_css_defines_cover_exactly_the_doc_tokens` pinnt die Menge fest; Chart-Farben kommen per `StyleContext.lookup_color()` aus den bestehenden Tokens. +7. **Fixtures:** synthetisch, von Hand gebaut (kontrastiert bewusst mit den echten `zorin_*`-Captures), **flach** unter `test/fixtures/` als `proc_*.txt` — Begründung: der Leak-Check iteriert `os.listdir` nicht rekursiv, flache Lage hält die neuen Fixtures automatisch unter dem Check. README kennzeichnet sie als synthetisch. +8. **Keine Änderungen** an Flutter-App, `additional/python`-Tools, Policy-, Packaging- oder Release-Dateien; `build.yml` bleibt unverändert (der bestehende GTK-Schritt `unittest discover -s prototype/gtk/tests` erfasst neue Testdateien automatisch). +9. **Implementierer dispatchen keine Subagenten** (weder Helfer noch Reviewer); Review kommt vom Controller nach dem Report. Reports nach `.superpowers/sdd/task-N-report.md` (gitignored Scratch). +10. **Bei wiederholten Fehlschlägen Root Cause statt blindem Patchen** (AGENT_PLAN); keine Behauptung „fertig" ohne Test. + +## Design-Entscheidungen (Controller; bindend für alle Tasks) + +**D1 — Wertumfang (5 Werte, genau diese; Erweiterung späterer Slices).** „Umfang legt der Spec fest" (Issue) → diese Tabelle ist der Spec, sie wandert 1:1 nach `docs/mla-next/MONITOR.md`: + +| Wert (id) | Quelle | Formel | Einheit | Anzeige | Toleranz | +|---|---|---|---|---|---| +| `cpu_percent` | `/proc/stat`, Zeile `cpu ` | `100 × Δbusy/Δtotal`, `busy = total − idle − iowait`, Delta zwischen zwei aufeinanderfolgenden Proben | % | 1 Dezimale | ±0,5 pp | +| `ram_percent` | `/proc/meminfo` | `100 × (MemTotal − MemAvailable)/MemTotal` | % | 1 Dezimale | ±0,2 pp | +| `psi_cpu_avg10` | `/proc/pressure/cpu` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | +| `psi_memory_avg10` | `/proc/pressure/memory` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | +| `psi_io_avg10` | `/proc/pressure/io` | `some avg10` unverändert übernommen | % | 2 Dezimalen | ±0,01 pp | + +Toleranzbegründung dokumentieren: Anzeige-Rundung (halbe letzte Stelle) plus Jiffy-Granularität bei CPU. Plausibilisierung = Nachrechnung aus denselben Fixtures mit unabhängiger Inline-Arithmetik im Test; zusätzlich Wertebereich `0 ≤ wert ≤ 100`. + +**D2 — Puffer/Intervall (Flutter-Parität):** Kapazität **60**, Intervall **3 s** wie `system_stats_service.dart:85-86` (`historyLength = 60`, `defaultInterval = Duration(seconds: 3)`) → 3-Minuten-Fenster. Die Flutter-Referenz nutzt für CPU `getCpuAverageLoad` (loadavg/Threads, `linux_system.dart:55-60`) — der GTK-Track nutzt bewusst `/proc/stat`-Auslastung; das ist eine **dokumentierte Abweichung** (nur Pufferlänge/Intervall sind die gemeinsame Basis). + +**D3 — Ringpuffer-Semantik:** `RingBuffer(capacity)` erhält zeitgestempelte Einträge; `append(ts, payload)` lehnt `ts <= letzter ts` ab (strikte Zeitmonotonie; Rückgabe `False`, Zähler `rejected`, keine Mutation). Überlauf: älteste Einträge fallen heraus, Reihenfolge bleibt älteste→neueste, `len ≤ capacity` immer. Der Puffer ist generisch (Payload beliebig); der Sampler nutzt `ValueSample(wall, value)` als Payload. + +**D4 — Lücken = `stale`, nie 0:** Der Puffer speichert **nur echte Werte**. `build_series(entries, *, interval, capacity, now)` erzeugt das feste Raster: genau `capacity` Slots, endend bei `floor(now/interval)`, Slot-Zuordnung `floor(ts/interval)`, bei mehreren Einträgen pro Slot gewinnt der jüngste, Slots ohne Eintrag werden `None` (stale). `None` ist von `0.0` strikt unterscheidbar; Diagramme brechen die Linie an `None` (nie bei 0 zeichnen). Ein Wert „stale" wird nie als 0 dargestellt oder interpretiert. + +**D5 — Sampler/Leseregeln:** `Sampler(read, clock, interval=3.0, capacity=60)`; `read(relpath)` und `clock()` sind injiziert (Tests: Fixture-Reader + deterministische Uhr; Produktion: `/proc`-Reader + `time.monotonic()`/`datetime.now()`). Pro Wert eigenes `try/except (OSError, ValueError)` → Lücke (Grund je Wert festgehalten, nur Dateipfad + Kurztext, keine Secrets). Erste CPU-Probe liefert keine Auslastung (Delta-Basis fehlt) → bewusst Lücke, kein Fake-Wert. Fehlende `pressure`-Dateien (alte Kernel) sind derselbe Lücken-Pfad. + +**D6 — UI:** Monitor-Ansicht ersetzt nicht die Shell-Struktur, sondern ergänzt den Monitor-Screen: je Wert eine Zeile aus Diagramm (`Gtk.DrawingArea`, Cairo, ~72 px) + Bildunterschrift **„Quelle · Einheit · Messzeit"**. Farbe nur per `StyleContext.lookup_color()` (z. B. `accent`, `border-subtle`, `muted`; Fallback `widget.get_color()` — kein Hex). Klick auf ein Diagramm = Drill-down in die **bestehende rechte Detail-Leiste** (Wert, Quelle, Formel, Toleranz, stale-Anzahl, letzte Samples monospace). Zeitgeber: `GLib.timeout_add_seconds(int(interval), …)`, single-source, sauberes Stoppen im `do_shutdown` (Source-ID entfernen), Doppelstart-Guard. Status-Chip auf dem Monitor-Screen: `messend` (tone-running). + +**D7 — #97-Grenze (bewusst offen, nicht behaupten):** Pause in unsichtbaren Ansichten/Hintergrund, Langlauf ohne Speicherzuwachs, Tastatur-/Screenreader-Bedienung und Textalternativen der Diagramme sind **#97**; #96 liefert nur die Andockpunkte (Sampler mit `start/stop`-fähigem Design via Source-ID). + +**D8 — Fixtures (exakte Inhalte, wörtlich übernehmen).** Diese Vorgaben sind bindend (Werte erzeugen das im Test gepinnte Ergebnis): + +`test/fixtures/proc_stat_a.txt` und `proc_stat_b.txt`: +``` +cpu 1000 50 200 5000 100 10 20 0 0 0 +cpu0 400 20 80 2000 40 4 8 0 0 0 +cpu1 300 15 60 1500 30 3 6 0 0 0 +cpu2 300 15 60 1500 30 3 6 0 0 0 +intr 123456 0 0 0 +ctxt 987654 +btime 1700000000 +processes 4242 +procs_running 1 +procs_blocked 0 +``` +(B-Variante: `cpu 1300 60 260 5600 120 12 24 0 0 0`, cpu0/cpu1/cpu2 konsistent aufsummiert, `intr 124000 0 0 0`, `ctxt 988000`, `processes 4300`, Rest gleich.) + +Daraus: `cpu_percent(a→b) = 100×376/996 = 37,7510… %` → Anzeige `37.8 %`. + +`proc_stat_truncated.txt`: `cpu 1 2` + `ctxt 42` (zu wenige Felder → `ValueError`). + +`proc_meminfo.txt` (MemTotal 16066996 kB, MemAvailable 9000000 kB, plus realistische Füllzeilen MemFree/Buffers/Cached/SwapTotal/SwapFree): `ram_percent = 100×7066996/16066996 = 43,9841… %` → Anzeige `44.0 %`. + +`proc_pressure_cpu.txt`: +``` +some avg10=0.52 avg60=0.31 avg300=0.25 total=1234567 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 +``` +`proc_pressure_memory.txt`: `some avg10=1.25 avg60=0.80 avg300=0.60 total=7654321` + full-Nullzeile. +`proc_pressure_io.txt`: `some avg10=2.50 avg60=1.75 avg300=1.20 total=2345678` + `full avg10=1.00 avg60=0.90 avg300=0.80 total=765432`. + +`test/fixtures/README.md` bekommt einen Abschnitt „Synthetische /proc-Snapshots (`proc_*.txt`)" (Zweck #96, synthetisch statt Capture, bekannte Deltas, flache Lage = automatisch unter dem Leak-Check). README.md ist vom Leak-Check ausgenommen, `*.txt` nicht — die Inhalte oben sind leak-check-sauber (verifizieren lassen). + +**D9 — Doc:** `docs/mla-next/MONITOR.md` ist das Spec-Artefakt (Abnahme „Quelle und Einheit dokumentiert"). Aufbau: §1 Werte & Quellen (Tabelle D1 + Formeln + Toleranzen), §2 Ringpuffer & Serien (D3/D4), §3 Sampler & Leseregeln (D5), §4 Darstellung & Drill-down (D6), §5 Vergleichsbasis Flutter (D2), §6 Tests & Fixtures (D8), §7 Grenzen/#97 (D7). Tasks füllen ihre Abschnitte; Task 5 finalisiert und kreuzprüft. + +## Tasks + +### Task 0 — Controller-Prep (direkt, kein Implementer) +Branch `feature/mla-96-monitor` von `de0026d` (erledigt), diese Plan-Datei, Ledger `.superpowers/sdd/progress.md` (gitignored, kein Commit). +Commit: `docs(mla): SDD-Plan #96 Lesender Monitor (#96)` + +### Task 1 — /proc-Parser, Fixtures, Wertespec +**Dateien:** `test/fixtures/proc_stat_a.txt`, `proc_stat_b.txt`, `proc_stat_truncated.txt`, `proc_meminfo.txt`, `proc_pressure_cpu.txt`, `proc_pressure_memory.txt`, `proc_pressure_io.txt` (Inhalte exakt D8); `test/fixtures/README.md` (Abschnitt ergänzen); neu `prototype/gtk/monitor_model.py` (gi-frei, kein I/O); neu `prototype/gtk/tests/test_monitor_model.py`; neu `docs/mla-next/MONITOR.md` (§1 vollständig, übrige §-Überschriften als Platzhalter, klar markiert „folgt in Task 3/4/5"). +**Modul-Inhalt:** `ValueSpec` (dataclass/NamedTuple: `id, label, unit, source, fixture, formula, decimals, tolerance`) + `VALUE_SPECS` (5 Einträge, Tabelle D1/D8, `fixture` = Dateiname in `test/fixtures/`); `parse_cpu_times(text) -> CpuTimes(total, idle, iowait)` (aggregierte `cpu `-Zeile; `total` = Summe der Felder 1–8 user…steal, **guest/guest_nice zählen nicht zusätzlich** — sie stecken laut `man proc` bereits in user/nice; `idle`=Feld 4, `iowait`=Feld 5, fehlendes iowait = 0; mindestens 4 Felder sonst `ValueError`); `cpu_percent(prev, cur) -> float | None` (None bei fehlendem prev oder Δtotal ≤ 0; `busy = total − idle − iowait`); `parse_meminfo(text) -> MemInfo(total_kib, available_kib)` (`MemTotal`/`MemAvailable` Pflicht, sonst `ValueError`); `meminfo_percent(info) -> float | None`; `parse_pressure_avg10(text) -> float` (`some`-Zeile Pflicht, `avg10=` Pflicht, sonst `ValueError`); Parse-Funktionen **werfen** `ValueError` mit klarer Botschaft — das Lücken-Handling macht der Sampler (Task 3). +**RED zuerst:** Testdatei schreiben, gegen das noch nicht existierende Modul laufen lassen (ImportError), Ausgabe im Report festhalten. Danach implementieren bis GREEN. +**Tests (mindestens):** Delta a→b ≈ 37,7510 (Toleranz ±0,5 pp gegen unabhängige Inline-Nachrechnung; Anzeige-Rundung separat geprüft `format`: → Task 4, hier nur Rohwert); Bereich 0≤x≤100 für alle fünf Fixture-Werte; RAM ≈ 43,9841 (±0,2); drei PSI-Werte exakt 0.52/1.25/2.50 (±0,01); `proc_stat_truncated.txt` → `ValueError`; Inline `"ctxt 1\n"` (keine `cpu `-Zeile) → `ValueError`; Inline `"full avg10=0.00 avg60=0.00 avg300=0.00 total=0\n"` (keine `some`-Zeile) → `ValueError`; `VALUE_SPECS`: 5 Einträge, ids eindeutig, jede `fixture`-Datei existiert. +**Gates:** GTK-Syntax (nur `monitor_model.py`), GTK-Tests (neue Datei + `test_tokens.py`), Python-Suite (Leak-Check über neue Fixtures muss grün sein). +Commit: `feat(mla): /proc-Parser samt Fixtures und Wertespec für den Monitor (#96)` + +### Task 2 — Ringpuffer +**Dateien:** `prototype/gtk/monitor_model.py` (erweitern), `prototype/gtk/tests/test_monitor_model.py` (erweitern). +**Inhalt:** `Sample` (NamedTuple `ts, payload`); `RingBuffer(capacity)` (`capacity ≥ 1` sonst `ValueError`): `append(ts, payload) -> bool` (ablehnen bei `ts <= letzter ts`: `False`, `rejected`-Zähler, keine Mutation), `entries` (Tupel älteste→neueste), `__len__`, `rejected`. Überlauf: ältestes fällt raus. +**RED zuerst** (Tests gegen nicht existierende Klasse). +**Tests (mindestens):** Überlauf capacity 3 nach 5 Appends → letzte 3 in Reihenfolge; Monotonie: gleicher ts und kleinerer ts abgelehnt (`False`, Zähler, Puffer unverändert); erster Append immer akzeptiert; capacity 1 Kante; `entries` liefert Tupel (nicht die interne Liste). +**Gates:** GTK-Syntax, GTK-Tests. +Commit: `feat(mla): Ringpuffer mit Monotonie- und Überlaufregeln (#96)` + +### Task 3 — Serienbildung (stale-Lücken) und Sampler +**Dateien:** `prototype/gtk/monitor_model.py` (erweitern), `prototype/gtk/tests/test_monitor_model.py` (erweitern), `docs/mla-next/MONITOR.md` (§2+§3 ausformulieren). +**Inhalt:** `build_series(entries, *, interval, capacity, now) -> tuple` (Länge exakt `capacity`; Slot `floor(ts/interval)`, Raster endend bei `floor(now/interval)`, jüngster Eintrag je Slot gewinnt, leere Slots `None`, Slots älter als das Fenster fallen weg); `stale_count(series) -> int`; `ValueSample(wall, value)`; `ProcReader(root='/proc')` mit `read(relpath) -> str` (`open` + `read`, nur Datei-I/O, keine Subprozesse); `Sampler(read, clock, interval=3.0, capacity=60)`: `tick()` liest alle 5 Werte (per-Wert `try/except` → Lücke, Grund in `gaps`-Dict), CPU-Delta über gespeicherte Vorprobe (erster Tick: keine CPU-Append), Appends in 5 Ringpuffer; `series(value_id, now=None)`, `latest(value_id)`, `stale_count(value_id, now=None)`, `start/stop`-frei (Zeitgeber gehört der UI — Andockpunkt #97). +**RED zuerst.** +**Tests (mindestens):** +- `build_series`: bekannte Lückenfolge (Einträge bei t=0 und t=15, interval 3, capacity 5, now=15) → `(None, None, None, None, wert)` und explizit `assertNotEqual(slot, 0.0)` für die Lücken; volles Raster (5 Einträge → 5 Werte); Fenster-Überlauf (10 Einträge, capacity 5 → nur letzte 5); zwei Einträge im selben Slot → jüngster gewinnt; `now` exakt auf Slot-Grenze. +- `Sampler` (Fake-Reader als Dict `{relpath: text}` mit austauschbarem `/proc/stat`-Inhalt zwischen Ticks; Fake-Uhr liefert (mono, wall) deterministisch): Tick 1 → CPU-Lücke (Delta-Basis), RAM/PSI Werte da; Tick 2 nach Tausch a→b → CPU ≈ 37,7510; fehlende `proc_pressure_io`-Datei (Reader wirft `OSError`) → Lücke + Grund, kein Crash; kaputtes meminfo (truncated-Fixture) → Lücke; `series` nach 3 Ticks: Raster stimmt, Lücken sind `None`; Monotonie der Uhr wird respektiert. +**Gates:** GTK-Syntax, GTK-Tests. +Commit: `feat(mla): Serienbildung mit stale-Lücken und /proc-Sampler (#96)` + +### Task 4 — GTK-UI: Diagramme (Einheit/Messzeit/Quelle), Drill-down, Verdrahtung +**Dateien:** neu `prototype/gtk/monitor_ui.py`; `prototype/gtk/mla_app.py`; `prototype/gtk/monitor_model.py` (Formatierer); `prototype/gtk/tests/test_monitor_model.py` (Formatierer-Tests); `prototype/gtk/tests/test_tokens.py` (Hardcode-Gate auf drei Dateien erweitern); `docs/mla-next/TOKENS.md` (§8: Gate-Umfang + ggf. neue Klassen); `docs/mla-next/MONITOR.md` (§4+§6 ausformulieren); ggf. `prototype/gtk/tokens.css` (nur klassenlos-farbfreie Zusatzklassen, wenn nötig — keine `@define-color`!). +**Inhalt:** +- Formatierer in `monitor_model.py` (rein, getestet): `format_value(spec, value)` → z. B. `"37.8 %"` (Dezimale aus spec, Punkt als Dezimaltrenner, `None` → `"stale"` **niemals** `"0.0"`); `format_caption(spec, newest_wall, stale)` → exakt `"Quelle: /proc/stat · Einheit: % · Messzeit: 14:32:05"` (bei stale > 0 Ergänzung `" · stale: N"`); `format_drilldown(spec, samples, stale)` → mehrzeiliger Text (aktueller Wert, Quelle, Formel, Toleranz, stale-Anzahl, letzte bis zu 10 Samples `"14:31:05 37.8 %"`). +- `monitor_ui.py`: `MonitorView(Gtk.Box)` mit `(sampler, on_drill)`; je Wert Zeile aus `Gtk.DrawingArea` (72 px, expandierend, `set_draw_func`) + Caption-Label; Cairo zeichnet Grundlinie (`border-subtle`) und Linie (`accent`) aus `self.lookup_color(...)` (Fallback `get_color()`, kein Hex), bricht an `None`; `Gtk.GestureClick` → `on_drill(value_id)`; `refresh()` aktualisiert Captions + `queue_draw()`. +- `mla_app.py`: `GLib` importieren; Monitor-Screen bekommt die `MonitorView`; Sampler (`ProcReader`, echte Uhr) lebt im `Window`; `GLib.timeout_add_seconds(3, self._tick)` beim Aufbau (Doppelstart-Guard), `_tick` → `sampler.tick()` + `monitor_view.refresh()`, `GLib.SOURCE_CONTINUE`; `App.do_shutdown` entfernt die Source; Klick-Drill-down füllt die rechte Detail-Leiste (`self.details`), Modulwahl „Systemmonitor" setzt Chip `messend` (tone `running`). +**RED zuerst:** Formatierer-Tests gegen fehlende Funktionen; die Token-Gate-Erweiterung ist erst grün, wenn keine Hex-Literale in den drei Dateien stehen (Known-Bad-Nachweis im Test bleibt). +**Tests:** Formatierer-Tests (exakte Strings, `None`→`"stale"`, Rundung 37,751→`"37.8 %"`, 43,984→`"44.0 %"`, 0.52→`"0.52 %"`, Caption exakt, Drill-down exakt); Token-Gate erweitert + grün; keine UI-Unit-Tests (GTK headless nicht importierbar) — UI-Beleg über Starts-Gates + Screenshot. +**Starts-Gates (ausführen!):** `GDK_BACKEND=wayland timeout 6 python3 prototype/gtk/mla_app.py` und `DISPLAY=:1 GDK_BACKEND=x11 timeout 6 …` → je Exit 124 (≥6 s, stderr leer); X11-Screenshot nach `/tmp` (xdotool+import, Rezept BASELINE §2) als Artefakt + Sichtprüfung Diagramme; **nicht** ins Repo. +**Gates:** GTK-Syntax (3 Dateien), GTK-Tests, Python-Suite, Starts-Gates. +Commit: `feat(mla): Monitor-Ansicht mit Einheit/Messzeit/Quelle und Drill-down (#96)` + +### Task 5 — Handoff, Gates-Frischlauf, Final-Review +**Dateien:** `docs/mla-next/VERIFY.md` (Sektion `## Handoff #96 — …` vor `## Agenten-Handoff`), `docs/mla-next/ISSUES.md` (Spiegel: #96-Abnahmeboxen 1–4 + Handoff-Boxen mit Evidenz abhaken), `docs/mla-next/MONITOR.md` (finalisieren, §5+§7). +**Ablauf:** alle Gates frisch ausführen und Ausgaben wörtlich sammeln; **Final-Whole-Branch-Review** adversarisch als Workflow mit den zwei geforderten Linsen (R1 Funktion/UX/Races; R2 Privilegien/Secrets/argv/IPC; auf Opus — härtere Verify-Stufe) + Synthese; Befunde beheben und scoped re-reviewen; Handoff-Sektion schreiben (Basis-SHA, Pfade, Status, Failing-Test/Fixture, Kernresultate, Gates-Tabelle mit wirklich ausgeführten Kommandos, rote/übersprungene Gates benannt, manuelle Zorin-Reste benannt, Reviewer-Verdicts, Rückfallplan, Grenzen/#97); Ledger abschließen. **Kein Push.** +Commit: `docs(mla): Handoff #96 — Gates, Reviewer, Rückfallplan (#96)` + +## Reviewer & Handoff-Zuordnung + +- **Je Task** nach dem Implementer-Report: Reviewer 1 (Funktion/UX/Races — Spez-Treue, Testschärfe, Zähler/Lebenszyklen) und Reviewer 2 (Privilegien/Secrets/argv/IPC — kein Root, kein Subprozess, keine Secrets, Leak-Lage) parallel; Fix-Runden bis beide APPROVED; Verdicts in den Ledger. +- **Task 5** bündelt die Handoff-Boxen 1–4 und den finalen Whole-Branch-Review (beide Linsen, `de0026d..HEAD`). +- Reviewer-Befunde, die dem Plantext widersprechen, entscheidet der Controller (Ruling + Ledger); bei wiederholten Fehlschlägen Root Cause. + +## Rückfallplan + +`git revert` der #96-Task-Commits genügt: neuer Branch ohne `main`-/Upstream-Berührung; alle Artefakte sind neue Dateien (`monitor_model.py`, `monitor_ui.py`, Tests, 7 Fixtures, MONITOR.md, Plan) plus kleine Änderungen (`mla_app.py`, `test_tokens.py`, `test/fixtures/README.md`, `TOKENS.md §8`, VERIFY/ISSUES). Kein Datenpfad, keine Unit, kein Packaging, keine Migration; polkit-Trinität unberührt. + +## Nicht-Ziele + +Kein Änderungen an Flutter-App/`la_core`/`additional/python`/`build.yml`/Packaging; keine IPC (A4), kein Backup-Cockpit (#63), keine Pause/A11y (#97); keine echten Schreibzugriffe, keine Admin-Aktionen. + +## Errata (append-only) + +- **E1 (2026-10-02, nach Task-1-Review R1):** Der RAM-Beispielwert in D8/Task 1 lautet korrekt + `100 × 7066996/16066996 = 43,98455… %` (nicht „43,9841…"). Anzeigeziel `44.0 %` und die + Testlogik (unabhängige Inline-Rechnung) sind unberührt; die betroffenen Doku-Stellen + (`MONITOR.md`, `test/fixtures/README.md`) wurden im Fix-Commit der Review-Runde korrigiert. +- **E2 (2026-10-17, nach Final-Whole-Branch-Review S1):** Das absolute Raster aus D4 + (`floor(ts/interval)`, Ende `floor(now/interval)`) kollidiert mit dem D6-Zeitgeber + `GLib.timeout_add_seconds(int(interval))`, der mit genau der Slotbreite taktet: liegt die + sitzungsfeste Tick-Phase knapp vor einer Slotgrenze, erzeugt schon ~60 ms Main-Loop-Verspätung + eine falsche `stale`-Lücke. D4 gilt ab hier mit **am letzten Tick verankertem Raster**: Index + vom Ende `k = floor((now − ts)/interval + 0.5)`, berücksichtigt nur `ts <= now` und + `0 <= k < capacity`; bei mehreren Einträgen je Slot gewinnt weiter der jüngste, leere Slots + bleiben `None` (stale, nie 0). Jitter unter `interval/2` verschiebt keinen Eintrag mehr. + Nicht-endliche `ts` fallen aus dem Raster (der ±inf-Monotonievertrag des Ringpuffers bleibt + unverändert). D6 (Zeitgeber) ist unberührt; Umsetzung im Fix-Commit der Final-Review-Runde, + Spec-Text in `MONITOR.md` §2. +- **E3 (2026-10-17, nach Scoped-Re-Review der Final-Fix-Runde 1, N1):** E2 verträgt nur Jitter + um eine feste Tick-Phase. Jeder Main-Loop-Stall ≥ 0,25 s verschiebt die Phase von + `timeout_add_seconds` dauerhaft um +1 s; aufsummiert ≥ 1,5 s runden ältere Einträge gegen das + am letzten Tick verankerte Raster einen Slot weiter und erzeugen wieder eine falsche + `stale`-Lücke. D4 gilt ab hier als **Kettenraster**: Der jüngste Eintrag (`ts <= now`, endlich) + liegt `k0 = floor((now − ts)/interval + 0.5)` Slots vor dem letzten; jeder ältere Eintrag liegt + `step = floor(Δ/interval + 0.5)` Slots vor dem nächstjüngeren berücksichtigten (Δ = dessen `ts` + minus eigenes `ts`); `step == 0` heißt selber Slot, der jüngere gewinnt; Index < 0 fällt weg. + Lücken entstehen damit nur bei echten Abständen ≥ 1,5 Intervallen, unabhängig von der + Phasendrift. Alle übrigen Regeln aus D4/E2 (nur echte Werte, `None` = stale, nie 0, + nicht-endliche `ts`/`now` fallen weg) bleiben. +- **E4 (2026-10-17, nach Copilot-Review von PR #119):** Task 2 („erster Append immer + akzeptiert", „`capacity ≥ 1` sonst `ValueError`") ist zu weit. Ein NaN als erster Zeitstempel + landete im Puffer und blockierte jeden späteren Append (`ts > nan` ist immer falsch); Brüche, + ganzzahlige Floats, `bool` und nan/inf liefen als Kapazität durch (inf/nan ohne Überlauf, + `2.5` erst in `build_series` als `TypeError`). Ab hier: `append` lehnt NaN **immer** ab, auch + als Erstappend (`False`, `rejected` + 1, keine Mutation); der erste endliche oder ±inf-Append + bleibt ohne Vergleich akzeptiert, der ±inf-Vertrag ist unverändert. `capacity` muss ein `int` + ≥ 1 sein (`bool` ausgeschlossen), sonst `ValueError`. D3 bleibt wörtlich gültig; der bindende + Text steht in `MONITOR.md` §2. diff --git a/prototype/gtk/README.md b/prototype/gtk/README.md index 36d9a857..d98fc211 100644 --- a/prototype/gtk/README.md +++ b/prototype/gtk/README.md @@ -1,6 +1,6 @@ # MLA GTK4-Prototyp -Isolierter, unprivilegierter UI-Spike auf `feature/mla-gtk-scaffold`. `mla_app.py` zeigt ausschließlich Demo-Daten. Kein IPC, keine echten Backup-/Security-Checks, keine Admin-Aktionen und keine Paketintegration. Der bestehende Flutter-Hub bleibt unverändert. +Isolierter, unprivilegierter UI-Spike auf `feature/mla-gtk-scaffold`. `mla_app.py` zeigt Demo-Daten; nur der Monitor-Screen (#96) liest echte Werte, ausschließlich lesend aus fünf festen `/proc`-Dateien (Spec `docs/mla-next/MONITOR.md`). Kein IPC, keine echten Backup-/Security-Checks, keine Admin-Aktionen und keine Paketintegration. Der bestehende Flutter-Hub bleibt unverändert. ## Lokaler Start auf Zorin @@ -15,6 +15,6 @@ python3 prototype/gtk/mla_app.py ## Geplanter Aufbau -`mla_app.py` ist die minimale Shell mit NavigationSplitView, HeaderBar, vier Fixture-Screens und rechter Kontextfläche. Module, Services, Tests und IPC werden erst in getrennten, überprüften Arbeitspaketen ergänzt. `docs/mla-next/AGENT_PLAN.md` gibt Reihenfolge und Sicherheitsgrenzen vor; `docs/mla-next/IPC_CONTRACT.md` ist ein Wire-Entwurf; `docs/mla-next/VERIFY.md` beschreibt Gates. +`mla_app.py` ist die minimale Shell mit NavigationSplitView, HeaderBar, drei Fixture-Screens, dem lesenden Monitor-Screen (#96: `monitor_model.py`, `monitor_ui.py`, Tests unter `tests/`) und rechter Kontextfläche. IPC und weitere Module folgen in getrennten, überprüften Arbeitspaketen. `docs/mla-next/AGENT_PLAN.md` gibt Reihenfolge und Sicherheitsgrenzen vor; `docs/mla-next/IPC_CONTRACT.md` ist ein Wire-Entwurf; `docs/mla-next/VERIFY.md` beschreibt Gates. Der frühere rsync-Democode aus der Unterhaltung gehört **nicht** zum produktiven Backup-Cockpit aus Issue #63 und wird hier nicht eingecheckt. diff --git a/prototype/gtk/mla_app.py b/prototype/gtk/mla_app.py index 43bced28..70673267 100644 --- a/prototype/gtk/mla_app.py +++ b/prototype/gtk/mla_app.py @@ -1,11 +1,16 @@ -"""GTK4/libadwaita-Scaffold: Demo-Daten, keine Systemaktionen.""" +"""GTK4/libadwaita-Shell: Monitor liest ausschließlich /proc (nur lesend), +keine Systemaktionen.""" import os +import time from pathlib import Path import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Adw, Gdk, Gio, Gtk +from gi.repository import Adw, Gdk, Gio, GLib, Gtk + +from monitor_model import ProcReader, Sampler, VALUE_SPECS, format_drilldown +from monitor_ui import MonitorView TOKENS_CSS = Path(__file__).resolve().with_name('tokens.css') TOKENS_DARK_CSS = Path(__file__).resolve().with_name('tokens-dark.css') @@ -24,11 +29,14 @@ MODULES = ( ('dashboard', 'Dashboard', 'Demo-Lagebild, keine Live-Daten'), - ('monitor', 'Systemmonitor', 'Datenadapter folgt'), + ('monitor', 'Systemmonitor', '60 × 3 s · /proc'), ('backup', 'Backup-Cockpit', 'Status unbekannt; Unit-Adapter folgt'), ('security', 'Security-Hub', 'Nur lesende Ansicht geplant'), ) +# Tone-Klassen der Shell (tokens.css, TOKENS.md §4) — der Chip trägt genau eine. +CHIP_TONES = ('ok', 'warn', 'crit', 'running', 'unknown', 'stale') + def apply_forced_color_scheme(): """Test-Affordance (TOKENS.md §7): MLA_FORCE_COLOR_SCHEME=light|dark erzwingt das Farbschema nur innerhalb dieser App über Adw.StyleManager — @@ -40,10 +48,31 @@ def apply_forced_color_scheme(): elif scheme == 'dark': manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK) +def build_detail_pane(work_area, detail_panel): + """Arbeitsfläche links, Detail-Leiste rechts (Trenner PANE_POSITION). + + PANE_POSITION zählt ab der linken Paned-Kante, also rechts der + Nav-Sidebar (bei 1200 px Fenster ist der Paned nur 920 px breit). + shrink-end-child=False macht DETAILS_MIN_WIDTH zur echten Grenze für den + Trenner; mit dem GTK4-Default TRUE schob er die Leiste über den rechten + Rand, wo der Paned sie abschnitt (Triage vor #97, MONITOR.md §7).""" + pane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL) + pane.set_start_child(work_area) + pane.set_end_child(detail_panel) + pane.set_shrink_end_child(False) + pane.set_position(PANE_POSITION) + return pane + class Window(Adw.ApplicationWindow): def __init__(self, app): super().__init__(application=app, title='Master Linux Assistant · Prototyp') self.set_default_size(*WINDOW_DEFAULT) + # Sampler lebt in der Shell: /proc-Reader + monotone/Wanduhr-Quelle; + # den einzigen Zeitgeber besitzt das Fenster (Single-Source, D6). + self.sampler = Sampler( + read=ProcReader().read, + clock=lambda: (time.monotonic(), time.time())) + self._tick_source_id = None self.stack = Gtk.Stack(hexpand=True, vexpand=True) self.details = Gtk.Label(xalign=0, yalign=0, wrap=True) self.details.add_css_class('mla-detail-text') @@ -80,6 +109,10 @@ def __init__(self, app): description_label = Gtk.Label(label=description, xalign=0, wrap=True) description_label.add_css_class('mla-desc') box.append(description_label) + if ident == 'monitor': + self.monitor_view = MonitorView(self.sampler, + self._show_drilldown) + box.append(self.monitor_view) self.stack.add_named(box, ident) detail_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=BOX_SPACING) detail_panel.set_size_request(DETAILS_MIN_WIDTH, -1) @@ -89,14 +122,45 @@ def __init__(self, app): detail_panel.append(detail_heading) detail_panel.append(self.status_chip) detail_panel.append(self.details) - pane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL) - pane.set_start_child(self.stack) - pane.set_end_child(detail_panel) - pane.set_position(PANE_POSITION) - content_view.set_content(pane) + content_view.set_content(build_detail_pane(self.stack, detail_panel)) nav.set_content(Adw.NavigationPage.new(content_view, 'Arbeitsbereich')) self.set_content(nav) sidebar.select_row(sidebar.get_row_at_index(0)) + # Initiales Refresh, Erstbefüllung per direktem _tick (toleriert den + # Aufruf außerhalb des Timeouts) und der einzige Mess-Zeitgeber; + # der Doppelstart-Guard armt nur, solange keine Source-ID steht. + self.monitor_view.refresh() + self._tick() + if self._tick_source_id is None: + self._tick_source_id = GLib.timeout_add_seconds( + int(self.sampler.interval), self._tick) + + def _tick(self): + """Ein Messlauf plus Refresh; für den GLib-Zeitgeber SOURCE_CONTINUE.""" + self.sampler.tick() + self.monitor_view.refresh() + return GLib.SOURCE_CONTINUE + + def stop_ticking(self): + """Zeitgeber abbestellen (idempotent); App.do_shutdown ruft dies.""" + if self._tick_source_id is not None: + GLib.source_remove(self._tick_source_id) + self._tick_source_id = None + + def _show_drilldown(self, value_id): + """Drill-down eines Werts in die rechte Detail-Leiste schreiben.""" + spec = next(spec for spec in VALUE_SPECS if spec.id == value_id) + self.details.set_text(format_drilldown( + spec, self.sampler.samples(value_id), + self.sampler.stale_count(value_id))) + self.details.add_css_class('mla-drilldown') + + def _set_chip(self, text, tone): + """Status-Chip umschalten (Tone-Klassen wie im Aufbau, TOKENS.md §4).""" + self.status_chip.set_text(text) + for name in CHIP_TONES: + self.status_chip.remove_css_class('mla-tone-' + name) + self.status_chip.add_css_class('mla-tone-' + tone) def select_module(self, _sidebar, row): if row is None: @@ -104,13 +168,22 @@ def select_module(self, _sidebar, row): ident = row.module_id self.stack.set_visible_child_name(ident) self.title.set_text(next(title for key, title, _ in MODULES if key == ident)) - self.details.set_text('Keine Live-Daten oder Admin-Aktionen. ' + - ('Ein Backup-Erfolg ist nicht belegt.' if ident == 'backup' - else 'Details und Datenquelle folgen.')) + self.details.remove_css_class('mla-drilldown') + if ident == 'monitor': + self.details.set_text('Klick auf ein Diagramm zeigt Quelle, Formel, ' + 'Toleranz und die letzten Proben.') + self._set_chip('messend', 'running') + else: + self.details.set_text('Keine Live-Daten oder Admin-Aktionen. ' + + ('Ein Backup-Erfolg ist nicht belegt.' + if ident == 'backup' + else 'Details und Datenquelle folgen.')) + self._set_chip('unbekannt', 'unknown') class App(Adw.Application): def __init__(self): super().__init__(application_id='dev.toqsick.MlaPrototype') + self._window = None def do_activate(self): apply_forced_color_scheme() @@ -120,8 +193,15 @@ def do_activate(self): Adw.StyleManager.get_default().connect( 'notify::dark', self._on_scheme_changed) window = Window(self) + self._window = window window.present() + def do_shutdown(self): + # Mess-Zeitgeber des Fensters sauber abbestellen (nur wenn gesetzt). + if self._window is not None: + self._window.stop_ticking() + Adw.Application.do_shutdown(self) + def _setup_css(self): display = Gdk.Display.get_default() css = Gtk.CssProvider() diff --git a/prototype/gtk/monitor_model.py b/prototype/gtk/monitor_model.py new file mode 100644 index 00000000..debeba75 --- /dev/null +++ b/prototype/gtk/monitor_model.py @@ -0,0 +1,505 @@ +"""Monitor-Modell des GTK-Systemmonitors (Issue #96): /proc-Parser, +Wertespec, Ringpuffer, Serienbildung, Sampler und Anzeige-Formatierer. + +Spec: `docs/mla-next/MONITOR.md` §1–§4; die maschinenlesbare Fassung der +Tabelle liegt hier als `VALUE_SPECS`. Die Parser, `build_series` und die +Formatierer sind gi-frei und machen kein I/O — sie arbeiten auf übergebenen +Textinhalten bzw. Einträgen; fehlende Pflichtfelder und nicht-endliche +Messwerte werfen `ValueError` mit klarer Botschaft. Datei-I/O gibt es +ausschließlich in `ProcReader`/`Sampler` (nur lesend, ohne Subprozess); das +Lücken-Handling (stale statt 0) übernimmt der Sampler. Kein Import aus dem +GTK-Stack — die CI läuft headless. +""" +import math +import os +import time +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from typing import NamedTuple + + +class CpuTimes(NamedTuple): + """Aggregierte CPU-Zähler (Jiffies) aus der `cpu `-Zeile von /proc/stat.""" + + total: int + idle: int + iowait: int + + +class MemInfo(NamedTuple): + """MemTotal/MemAvailable aus /proc/meminfo, in KiB.""" + + total_kib: int + available_kib: int + + +@dataclass(frozen=True) +class ValueSpec: + """Ein Monitorwert: Quelle, Formel, Anzeige und Prüftoleranz (D1).""" + + id: str + label: str + unit: str + source: str + fixture: str + formula: str + decimals: int + tolerance: float + + +# cpu_percent entsteht als Delta zweier Proben (proc_stat_a.txt → b); +# `fixture` nennt die jüngere Probe des Paares. +VALUE_SPECS = ( + ValueSpec( + id='cpu_percent', + label='CPU', + unit='%', + source='/proc/stat', + fixture='proc_stat_b.txt', + formula='100 × Δbusy/Δtotal, busy = total − idle − iowait, ' + 'Delta zweier aufeinanderfolgender Proben', + decimals=1, + tolerance=0.5, + ), + ValueSpec( + id='ram_percent', + label='RAM', + unit='%', + source='/proc/meminfo', + fixture='proc_meminfo.txt', + formula='100 × (MemTotal − MemAvailable)/MemTotal', + decimals=1, + tolerance=0.2, + ), + ValueSpec( + id='psi_cpu_avg10', + label='PSI CPU', + unit='%', + source='/proc/pressure/cpu', + fixture='proc_pressure_cpu.txt', + formula='some avg10 unverändert übernommen', + decimals=2, + tolerance=0.01, + ), + ValueSpec( + id='psi_memory_avg10', + label='PSI Speicher', + unit='%', + source='/proc/pressure/memory', + fixture='proc_pressure_memory.txt', + formula='some avg10 unverändert übernommen', + decimals=2, + tolerance=0.01, + ), + ValueSpec( + id='psi_io_avg10', + label='PSI I/O', + unit='%', + source='/proc/pressure/io', + fixture='proc_pressure_io.txt', + formula='some avg10 unverändert übernommen', + decimals=2, + tolerance=0.01, + ), +) + + +def parse_cpu_times(text: str) -> CpuTimes: + """Aggregierte `cpu `-Zeile aus /proc/stat: total, idle, iowait. + + `total` ist die Summe der Felder 1–8 (user, nice, system, idle, iowait, + irq, softirq, steal). guest/guest_nice (Felder 9/10) zählen NICHT + zusätzlich — sie stecken laut `man proc` bereits in user/nice. `idle` + ist Feld 4, `iowait` Feld 5 (fehlend = 0). Weniger als vier Felder sind + kein gültiger Snapshot → ValueError. + """ + for line in text.splitlines(): + if line.startswith('cpu '): + fields = [int(part) for part in line.split()[1:]] + if len(fields) < 4: + raise ValueError( + 'cpu -Zeile braucht mindestens 4 Felder, hat %d' + % len(fields)) + return CpuTimes( + total=sum(fields[:8]), + idle=fields[3], + iowait=fields[4] if len(fields) > 4 else 0, + ) + raise ValueError('keine aggregierte cpu -Zeile gefunden') + + +def cpu_percent(prev: CpuTimes | None, cur: CpuTimes) -> float | None: + """Busy-Anteil zwischen zwei Proben in Prozent. + + `busy = total − idle − iowait`, `100 × Δbusy/Δtotal`. `None` ohne + Vorgängerprobe (erste Probe) oder bei `Δtotal <= 0` — der Aufrufer + behandelt das als Lücke (stale), nie als 0. + """ + if prev is None: + return None + delta_total = cur.total - prev.total + if delta_total <= 0: + return None + delta_busy = ((cur.total - cur.idle - cur.iowait) + - (prev.total - prev.idle - prev.iowait)) + return 100.0 * delta_busy / delta_total + + +def parse_meminfo(text: str) -> MemInfo: + """`MemTotal`/`MemAvailable` aus /proc/meminfo in KiB; beide Pflicht.""" + values = {} + for line in text.splitlines(): + key, _separator, rest = line.partition(':') + if key in ('MemTotal', 'MemAvailable'): + parts = rest.split() + if not parts: + raise ValueError('%s ohne Wert in /proc/meminfo' % key) + values[key] = int(parts[0]) + missing = [key for key in ('MemTotal', 'MemAvailable') + if key not in values] + if missing: + raise ValueError('meminfo ohne %s' % ', '.join(missing)) + return MemInfo(total_kib=values['MemTotal'], + available_kib=values['MemAvailable']) + + +def meminfo_percent(info: MemInfo) -> float | None: + """Belegter RAM-Anteil in Prozent; `None` bei `total <= 0`.""" + if info.total_kib <= 0: + return None + return 100.0 * (info.total_kib - info.available_kib) / info.total_kib + + +def parse_pressure_avg10(text: str) -> float: + """`some avg10` aus /proc/pressure/*; some-Zeile und avg10= sind Pflicht. + + Ein nicht-endlicher Wert (`nan`, `inf`) ist kein Messwert → ValueError. + """ + for line in text.splitlines(): + if line.startswith('some '): + for token in line.split()[1:]: + if token.startswith('avg10='): + value = float(token.split('=', 1)[1]) + if not math.isfinite(value): + raise ValueError( + 'avg10 ist nicht endlich: %r' % token) + return value + raise ValueError('some-Zeile ohne avg10=') + raise ValueError('keine some-Zeile gefunden') + + +class Sample(NamedTuple): + """Zeitgestempelter Eintrag des Ringpuffers: Zeitstempel + beliebiger Payload.""" + + ts: float + payload: object + + +class RingBuffer: + """Ringpuffer fester Kapazität für zeitgestempelte Einträge (D3). + + Reine Datenstruktur ohne Wertelogik, Parsing oder I/O. `append` nimmt nur + streng größere Zeitstempel als der letzte Eintrag an (strikte + Zeitmonotonie); abgelehnte Appends mutieren nichts und zählen in + `rejected`. Bei Überlauf fällt der älteste Eintrag heraus, die Reihenfolge + bleibt älteste→neueste und `len(ring) <= capacity` gilt immer. + `capacity` ist eine Slot-Anzahl: nur `int` ≥ 1, sonst `ValueError`. + """ + + def __init__(self, capacity: int): + # bool ist eine int-Unterklasse, aber keine Slot-Anzahl; Floats (auch + # 3.0, nan, inf) würden den Überlauf aushebeln oder erst in + # build_series scheitern ([None] * 2.5 → TypeError). + if (isinstance(capacity, bool) or not isinstance(capacity, int) + or capacity < 1): + raise ValueError( + 'capacity muss eine ganze Zahl >= 1 sein, ist %r' % capacity) + self._capacity = capacity + self._entries: list[Sample] = [] + self.rejected = 0 + + def __len__(self) -> int: + return len(self._entries) + + @property + def entries(self) -> tuple[Sample, ...]: + """Momentaufnahme älteste→neueste; nie die interne Liste.""" + return tuple(self._entries) + + def append(self, ts: float, payload: object) -> bool: + """Eintrag anhängen, wenn `ts` streng größer als der letzte ist. + + `NaN`-Zeitstempel werden immer abgelehnt, auch als erster Append — + ein NaN im Puffer ließe jeden späteren Vergleich `ts > nan` scheitern + und blockierte den Wert dauerhaft. `+inf`/`-inf` nehmen als geordnete + Werte (größter/kleinster Zeitstempel) am normalen Vergleich teil: + `+inf` wird angenommen, solange der letzte Eintrag kleiner ist; + `-inf` wird bei bestehendem Puffer abgelehnt (nur als erster Append + akzeptiert). + """ + if math.isnan(ts) or (self._entries + and not ts > self._entries[-1].ts): + # NaN vor dem Leerpuffer-Kurzschluss abweisen; ±inf laufen durch + # den normalen Vergleich: +inf wird angenommen, -inf nur als + # Erstappend. + self.rejected += 1 + return False + self._entries.append(Sample(ts=ts, payload=payload)) + if len(self._entries) > self._capacity: + del self._entries[0] + return True + + +class ValueSample(NamedTuple): + """Ein Messwert für die Anzeige: Wanduhr (`wall`) und Wert (`value`). + + Der Ringpuffer-Zeitstempel (`Sample.ts`) ist die monotone Uhr; `wall` + trägt die Wanduhr nur für die „Messzeit"-Anzeige und wird nie für + Slot-Zuordnung oder Monotonie verwendet (D5). + """ + + wall: float + value: float + + +def build_series(entries: Iterable[Sample], *, interval: float, + capacity: int, now: float) -> tuple: + """Feste Raster-Serie der Länge `capacity` aus Ringpuffer-Einträgen (D4/E3). + + Kettenraster: Der jüngste Eintrag mit endlichem `ts <= now` liegt + `k0 = floor((now − ts)/interval + 0,5)` Slots vor dem letzten Slot + (Index `capacity − 1 − k0`); jeder ältere liegt + `step = floor(Δ/interval + 0,5)` Slots vor dem zuletzt berücksichtigten, + nächstjüngeren Eintrag (Δ = dessen `ts` minus eigenes `ts`). `step == 0` + heißt selber Slot — der jüngere Wert bleibt; ein Index unter 0 beendet + die Kette (alle älteren fallen ebenfalls weg). Lücken (`None`, stale, + strikt unterscheidbar von `0.0`) entstehen so nur bei echten Abständen + ab 1,5 Intervallen; Jitter und die Phasendrift des Zeitgebers (er + taktet mit genau der Slotbreite, jeder Main-Loop-Stall verschiebt seine + Phase) verschieben keinen Eintrag. Viele Abstände knapp unter 1,5 + Intervallen können das Fenster zeitlich stauchen. Einträge in der + Zukunft (`ts > now`) oder mit nicht-endlichem `ts` fallen weg; ein + nicht-endliches `now` ergibt das leere Raster. Rückgabe: Tupel der + `payload.value`-Werte älteste→neueste (links→rechts im Raster). + """ + values = [None] * capacity + if not math.isfinite(now): + return tuple(values) + candidates = sorted( + (entry for entry in entries + if math.isfinite(entry.ts) and entry.ts <= now), + key=lambda entry: entry.ts, reverse=True) + anchor_ts = now # erst der jüngste Eintrag verankert die Kette + anchor_index = capacity - 1 + chained = False + for entry in candidates: + offset = (anchor_ts - entry.ts) / interval + if not math.isfinite(offset): + continue + step = math.floor(offset + 0.5) + if chained and step == 0: + continue # selber Slot wie der jüngere, berücksichtigte Eintrag + index = anchor_index - step + if index < 0: + break + values[index] = entry.payload.value + anchor_ts = entry.ts + anchor_index = index + chained = True + return tuple(values) + + +def stale_count(series: Iterable[float | None]) -> int: + """Anzahl der `None`-Slots (stale-Lücken) einer Serie.""" + return sum(1 for slot in series if slot is None) + + +class ProcReader: + """Minimaler Lese-Zugriff auf Dateien unter `/proc` (D5). + + Nur `open` + `read`, ausschließlich lesend: keine Subprozesse, kein + Shell-Aufruf. `root` ist injizierbar (Tests lesen aus einem + tmp-Verzeichnis); `read` bekommt Pfade relativ zu `root`, abgeleitet + aus dem `source`-Feld der `VALUE_SPECS` (z. B. `'stat'`, + `'pressure/cpu'`). + """ + + def __init__(self, root: str = '/proc'): + self._root = root + + def read(self, relpath: str) -> str: + """Datei `root/relpath` vollständig als Text lesen.""" + path = os.path.join(self._root, relpath) + with open(path, encoding='utf-8') as handle: + return handle.read() + + +class Sampler: + """Zeitgeberfreier /proc-Sampler für die fünf Werte (D5). + + `read(relpath)` und `clock()` sind injiziert; `clock()` liefert + `(mono, wall)` — Ringpuffer-Zeitstempel ist die monotone Uhr, + `ValueSample.wall` die Wanduhr für die Anzeige. `tick()` liest jeden + Wert einzeln: Lesefehler und Parser-Fehler werden je Wert zur Lücke in + `gaps`, nie zu einem Ersatzwert. Bewusst ohne `start`/`stop` — den + Zeitgeber besitzt die UI (GLib-Main-Loop, Andockpunkt #97); die ganze + Kette läuft single-threaded, darum braucht der Puffer keinen Lock. + """ + + def __init__(self, read: Callable[[str], str], + clock: Callable[[], tuple[float, float]], + interval: float = 3.0, capacity: int = 60): + self._read = read + self._clock = clock + self._interval = interval + self._capacity = capacity + self._rings = {spec.id: RingBuffer(capacity) for spec in VALUE_SPECS} + self._previous_cpu: CpuTimes | None = None + self._last_mono: float | None = None + #: Grund je Wert, der im letzten Tick keine frische Probe bekam. + self.gaps: dict[str, str] = {} + + @property + def interval(self) -> float: + """Messintervall in Sekunden (für den Zeitgeber der UI, #97).""" + return self._interval + + @property + def capacity(self) -> int: + """Slots je Serie (Rasterbreite der Diagramme).""" + return self._capacity + + def tick(self) -> None: + """Alle fünf Werte einmal lesen und je Wert in den Puffer anhängen. + + Zu Tick-Beginn wird `gaps` geleert. Jeder Wert hat sein eigenes + `try/except (OSError, ValueError)`; Fehler werden zur Lücke, die + übrigen Werte laufen weiter. Der erste CPU-Tick hat keine + Delta-Basis und wird bewusst zur Lücke (kein Fake-Wert); die + Vorprobe wird trotzdem gespeichert und nach jedem Tick aktualisiert. + """ + mono, wall = self._clock() + self.gaps.clear() + self._last_mono = mono + for spec in VALUE_SPECS: + relpath = spec.source.removeprefix('/proc/') + try: + value, gap = self._measure(spec, relpath) + except (OSError, ValueError) as exc: + self.gaps[spec.id] = '%s: %s' % (type(exc).__name__, relpath) + continue + if gap is not None: + self.gaps[spec.id] = gap + continue + sample = ValueSample(wall=wall, value=value) + if not self._rings[spec.id].append(mono, sample): + self.gaps[spec.id] = 'Zeit nicht monoton' + + def _measure(self, spec: ValueSpec, + relpath: str) -> tuple[float | None, str | None]: + """Einen Wert lesen und berechnen: `(value, None)` oder `(None, Grund)`. + + Bewusste Nicht-Werte (fehlende CPU-Delta-Basis, ungültiges Delta, + meminfo ohne belegbaren Wert) bekommen einen Lücken-Grund; Lesen + und Parsen werfen `OSError`/`ValueError` wie Reader und Parser. + """ + text = self._read(relpath) + if spec.id == 'cpu_percent': + current = parse_cpu_times(text) + previous = self._previous_cpu + self._previous_cpu = current + value = cpu_percent(previous, current) + if value is None: + if previous is None: + return None, 'Delta-Basis fehlt (erster Tick)' + return None, 'Delta ungueltig' + return value, None + if spec.id == 'ram_percent': + value = meminfo_percent(parse_meminfo(text)) + else: + value = parse_pressure_avg10(text) + if value is None: + return None, 'Wert ungueltig: %s' % relpath + return value, None + + def samples(self, value_id: str) -> tuple[ValueSample, ...]: + """Alle gesammelten Werte älteste→neueste (fürs Drill-down).""" + entries = self._rings[value_id].entries + return tuple(entry.payload for entry in entries) + + def latest(self, value_id: str) -> ValueSample | None: + """Jüngster Wert oder `None`, solange keiner gesammelt wurde.""" + entries = self._rings[value_id].entries + return entries[-1].payload if entries else None + + def series(self, value_id: str, now: float | None = None) -> tuple: + """Serie dieses Werts über das feste Raster (`now=None` → letzter Tick). + + Vor dem ersten Tick ist die Serie vollständig `None` der Länge + `capacity` (kein Fehler). + """ + if now is None: + now = 0.0 if self._last_mono is None else self._last_mono + return build_series( + self._rings[value_id].entries, + interval=self._interval, capacity=self._capacity, now=now) + + def stale_count(self, value_id: str, now: float | None = None) -> int: + """Anzahl stale-Slots dieses Werts (Modul-Funktion auf `series`).""" + return stale_count(self.series(value_id, now)) + + +# --- Anzeige-Formatierer (Task 4, MONITOR.md §4) --------------------------- +# Rein und gi-frei; die Zeitfelder nutzen die Wanduhr `ValueSample.wall` +# (bzw. `newest_wall`) ausschließlich zur Anzeige, nie für Slot-Logik (D5). +# Kein `None` wird je zu `0` — Lücken heißen exakt `stale` (D4). + +def format_value(spec: ValueSpec, value: float | None) -> str: + """Anzeigewert aus `spec.decimals` + `spec.unit`, Punkt als Trenner. + + `None` (Lücke) wird exakt `stale` — nie `0.0` oder `0`. + """ + if value is None: + return 'stale' + return f'{value:.{spec.decimals}f} {spec.unit}' + + +def format_caption(spec: ValueSpec, newest_wall: float | None, + stale: int) -> str: + """Bildunterschrift `Quelle: … · Einheit: … · Messzeit: HH:MM:SS`. + + Zeit aus `newest_wall` per `time.localtime`; ohne Messwert steht `–` + (Halbgeviertstrich) im Zeitfeld. Das Suffix ` · stale: N` erscheint nur + bei `stale > 0`. + """ + if newest_wall is None: + when = '–' + else: + when = time.strftime('%H:%M:%S', time.localtime(newest_wall)) + caption = f'Quelle: {spec.source} · Einheit: {spec.unit} · Messzeit: {when}' + if stale > 0: + caption += f' · stale: {stale}' + return caption + + +def format_drilldown(spec: ValueSpec, samples: Sequence[ValueSample], + stale: int) -> str: + """Drill-down-Text: Kopfzeilen plus höchstens die letzten 10 Samples. + + Reihenfolge: `Label: Wert`, `Quelle:`, `Formel:`, `Toleranz: ±N pp`, + `stale: N`, danach die Samples älteste→neueste als + `HH:MM:SS Wert` (zwei Leerzeichen; Zeit aus `sample.wall`). Ohne + Samples bleiben die Sample-Zeilen weg. + """ + newest = format_value(spec, samples[-1].value) if samples else 'stale' + lines = [ + f'{spec.label}: {newest}', + f'Quelle: {spec.source}', + f'Formel: {spec.formula}', + f'Toleranz: ±{spec.tolerance} pp', + f'stale: {stale}', + ] + for sample in samples[-10:]: + stamp = time.strftime('%H:%M:%S', time.localtime(sample.wall)) + lines.append(f'{stamp} {format_value(spec, sample.value)}') + return '\n'.join(lines) diff --git a/prototype/gtk/monitor_ui.py b/prototype/gtk/monitor_ui.py new file mode 100644 index 00000000..1d8f4103 --- /dev/null +++ b/prototype/gtk/monitor_ui.py @@ -0,0 +1,133 @@ +"""GTK-Ansicht des Systemmonitors: Diagramme, Captions, Drill-down (#96). + +Je `VALUE_SPECS`-Eintrag eine Zeile aus Caption-Label und +`Gtk.DrawingArea`; Cairo zeichnet Grundlinie und Datenpolyline, die Farben +kommen ausschließlich über `StyleContext.lookup_color()` aus den +bestehenden Tokens (TOKENS.md §1/§8) — kein Hex/rgba im Quelltext. Den +einzigen Zeitgeber besitzt die Shell (`mla_app.py`); `refresh()` zieht nur +Captions und Zeichenflächen nach (D6, Single-Source-Timer). +""" +import math + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Gdk', '4.0') +from gi.repository import Gdk, Gtk + +from monitor_model import VALUE_SPECS, format_caption + +CHART_MIN_HEIGHT = 72 # Diagrammhöhe in px (D6) +ROW_SPACING = 12 # space3 (TOKENS.md §2) +LABEL_SPACING = 4 # space1 (TOKENS.md §2) +BASELINE_TOKEN = 'border-subtle' +LINE_TOKEN = 'accent' +Y_MAX_PERCENT = 100.0 # feste Y-Skala 0…100 % (D6) +LINE_WIDTH = 1.0 # Linienbreite von Grundlinie und Polyline +POINT_RADIUS = 1.5 # Einzelpunkt (Wert ohne Nachbarn), > Linienbreite/2 + + +class MonitorView(Gtk.Box): + """Monitor-Screen: je Wert Caption + Diagramm; Klick → Drill-down. + + `on_drill(value_id)` wird bei Klick auf eine Zeile aufgerufen; den + Refresh-Zyklus stößt die Shell an (`refresh()`), diese Ansicht kennt + keinen eigenen Timer. + """ + + def __init__(self, sampler, on_drill): + super().__init__(orientation=Gtk.Orientation.VERTICAL, + spacing=ROW_SPACING) + self._sampler = sampler + self._on_drill = on_drill + self._captions = {} + self._areas = [] + self.set_vexpand(True) + for spec in VALUE_SPECS: + row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, + spacing=LABEL_SPACING) + caption = Gtk.Label(xalign=0, wrap=True) + caption.add_css_class('mla-desc') + area = Gtk.DrawingArea() + area.set_size_request(-1, CHART_MIN_HEIGHT) + area.set_hexpand(True) + area.set_vexpand(True) + area.set_draw_func(self._draw_chart, spec) + row.append(caption) + row.append(area) + gesture = Gtk.GestureClick() + gesture.set_button(Gdk.BUTTON_PRIMARY) # nur Primärklick; Touch/Tap zählt als primär + gesture.connect('released', self._on_row_clicked, spec.id) + row.add_controller(gesture) + self.append(row) + self._captions[spec.id] = caption + self._areas.append(area) + self.refresh() + + def _on_row_clicked(self, _gesture, _n_press, _x, _y, value_id): + """Klick (losgelassen) auf eine Zeile: Drill-down für ihren Wert.""" + self._on_drill(value_id) + + def _color(self, widget, token): + """Token-Farbe aus dem Style-Context; Fallback: aktuelle Textfarbe.""" + found, color = widget.get_style_context().lookup_color(token) + if not found: + color = widget.get_color() + return (color.red, color.green, color.blue, color.alpha) + + def _draw_chart(self, area, context, width, height, spec): + """Ein Diagramm zeichnen: Grundlinie + Polyline über die Slots. + + X: Slot `i` liegt bei `(i + 0,5) × Breite/Kapazität` (älteste + links, Raster wie `build_series`). Y: 0 % auf der Grundlinie, + 100 % am oberen Rand. `None`-Slots brechen die Linie — nie durch + 0 ziehen; ein Wert ohne Nicht-`None`-Nachbarn links und rechts + (eine Linie hätte keine Länge) wird als gefüllter Punkt in der + Linienfarbe gezeichnet; eine leere Serie zeigt nur die Grundlinie. + """ + if width <= 0 or height <= 0: + return + ground_y = height - 1.0 + context.set_line_width(LINE_WIDTH) + context.set_source_rgba(*self._color(area, BASELINE_TOKEN)) + context.move_to(0.0, ground_y) + context.line_to(width, ground_y) + context.stroke() + + series = self._sampler.series(spec.id) + if not any(value is not None for value in series): + return + context.set_source_rgba(*self._color(area, LINE_TOKEN)) + step = width / self._sampler.capacity + drawing = False + dots = [] + last = len(series) - 1 + for index, value in enumerate(series): + if value is None: + drawing = False # Linie bricht an Lücken (D4) + continue + x = (index + 0.5) * step + ratio = min(max(value, 0.0), Y_MAX_PERCENT) / Y_MAX_PERCENT + y = ground_y - ratio * ground_y + if (index == 0 or series[index - 1] is None) and ( + index == last or series[index + 1] is None): + dots.append((x, y)) # Einzelwert: eine Linie bliebe unsichtbar + if drawing: + context.line_to(x, y) + else: + context.move_to(x, y) + drawing = True + context.stroke() + for x, y in dots: + context.new_sub_path() + context.arc(x, y, POINT_RADIUS, 0.0, 2.0 * math.pi) + context.fill() + + def refresh(self): + """Captions auf den letzten Tick ziehen und alles neu zeichnen.""" + for spec in VALUE_SPECS: + latest = self._sampler.latest(spec.id) + newest_wall = latest.wall if latest is not None else None + self._captions[spec.id].set_text(format_caption( + spec, newest_wall, self._sampler.stale_count(spec.id))) + for area in self._areas: + area.queue_draw() diff --git a/prototype/gtk/tests/test_monitor_model.py b/prototype/gtk/tests/test_monitor_model.py new file mode 100644 index 00000000..1d5c6f19 --- /dev/null +++ b/prototype/gtk/tests/test_monitor_model.py @@ -0,0 +1,1041 @@ +"""Tests für das Monitor-Modell (Issue #96): /proc-Parser, Wertespec, +Ringpuffer, Serienbildung, Sampler und Anzeige-Formatierer. + +Die erwarteten Werte rechnet jeder Test unabhängig aus den Fixture-Texten +nach (kleine Inline-Zerlegung, bewusst NICHT die Produktionsfunktionen) — +so kann ein Formelfehler in monitor_model.py nicht durch dieselbe Rechnung +im Test verdeckt werden. + +Läuft mit: python3 -m unittest discover -s prototype/gtk/tests (Repo-Root). +Kein gi-Import: die CI läuft headless. +""" +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path + +GTK_DIR = Path(__file__).resolve().parents[1] +FIXTURE_DIR = GTK_DIR.parent.parent / 'test' / 'fixtures' +sys.path.insert(0, str(GTK_DIR)) + +import monitor_model # Modul liegt neben diesem Testverzeichnis + + +def fixture_text(name): + """Fixture aus test/fixtures/ lesen; fehlende Datei ist ein Testfehler.""" + with open(FIXTURE_DIR / name, encoding='utf-8') as handle: + return handle.read() + + +def inline_cpu_times(text): + """Unabhängige Zerlegung der aggregierten cpu -Zeile: (total, idle, iowait).""" + for line in text.splitlines(): + if line.startswith('cpu '): + fields = [int(part) for part in line.split()[1:]] + return (sum(fields[:8]), fields[3], + fields[4] if len(fields) > 4 else 0) + raise AssertionError('keine cpu -Zeile in %r' % text) + + +def inline_meminfo(text): + """Unabhängige Zerlegung von MemTotal/MemAvailable aus /proc/meminfo.""" + values = {} + for line in text.splitlines(): + key, _separator, rest = line.partition(':') + if key in ('MemTotal', 'MemAvailable') and rest.split(): + values[key] = int(rest.split()[0]) + return values['MemTotal'], values['MemAvailable'] + + +def inline_pressure_some_avg10(text): + """Unabhängige Zerlegung des some-avg10-Werts aus /proc/pressure/*.""" + for line in text.splitlines(): + if line.startswith('some '): + for token in line.split()[1:]: + if token.startswith('avg10='): + return float(token.split('=', 1)[1]) + raise AssertionError('keine some-Zeile mit avg10= in %r' % text) + + +class CpuParserTests(unittest.TestCase): + def test_parse_cpu_times_reads_the_aggregated_line(self): + text = fixture_text('proc_stat_a.txt') + expected_total, expected_idle, expected_iowait = inline_cpu_times(text) + # Anker: die Fixture-Arithmetik aus dem Plan (6380/5000/100). + self.assertEqual((expected_total, expected_idle, expected_iowait), + (6380, 5000, 100)) + times = monitor_model.parse_cpu_times(text) + self.assertEqual((times.total, times.idle, times.iowait), + (expected_total, expected_idle, expected_iowait)) + + def test_guest_times_are_not_counted_twice(self): + # guest/guest_nice (Felder 9/10) stecken laut man proc bereits in + # user/nice und dürfen total nicht ein zweites Mal erhöhen. + times = monitor_model.parse_cpu_times( + 'cpu 10 0 10 100 0 0 0 0 999 999\n') + self.assertEqual(times.total, 120) + self.assertEqual(times.idle, 100) + + def test_steal_counts_but_guest_does_not_double_count(self): + # Felder 1–8 (user … steal) ergeben 36; die Felder 9/10 + # (guest/guest_nice = 9/10) dürfen laut man proc nicht zusätzlich + # zählen. Damit fallen die Mutanten sum(fields[:7]) → 28 und + # sum(fields[:10]) → 55. + times = monitor_model.parse_cpu_times('cpu 1 2 3 4 5 6 7 8 9 10\n') + self.assertEqual( + times, monitor_model.CpuTimes(total=36, idle=4, iowait=5)) + + def test_four_fields_are_the_minimum_and_missing_iowait_is_zero(self): + # Feld 4 ist idle (hier 4), Feld 5 fehlt → iowait 0. + times = monitor_model.parse_cpu_times('cpu 1 2 3 4\n') + self.assertEqual((times.total, times.idle, times.iowait), (10, 4, 0)) + + def test_too_short_cpu_line_raises(self): + with self.assertRaises(ValueError): + monitor_model.parse_cpu_times('cpu 1 2 3\n') + + def test_truncated_fixture_raises(self): + with self.assertRaises(ValueError) as caught: + monitor_model.parse_cpu_times(fixture_text('proc_stat_truncated.txt')) + self.assertTrue(str(caught.exception)) + + def test_missing_cpu_line_raises(self): + with self.assertRaises(ValueError): + monitor_model.parse_cpu_times('ctxt 1\n') + + +class CpuPercentTests(unittest.TestCase): + def test_percent_matches_independent_fixture_delta(self): + text_a = fixture_text('proc_stat_a.txt') + text_b = fixture_text('proc_stat_b.txt') + total_a, idle_a, iowait_a = inline_cpu_times(text_a) + total_b, idle_b, iowait_b = inline_cpu_times(text_b) + expected = 100.0 * ( + (total_b - idle_b - iowait_b) - (total_a - idle_a - iowait_a) + ) / (total_b - total_a) + # Anker gegen den Plan: Δbusy 376 / Δtotal 996. + self.assertAlmostEqual(expected, 100.0 * 376 / 996, places=6) + + got = monitor_model.cpu_percent( + monitor_model.parse_cpu_times(text_a), + monitor_model.parse_cpu_times(text_b)) + self.assertAlmostEqual(got, expected, delta=0.5) + self.assertGreaterEqual(got, 0.0) + self.assertLessEqual(got, 100.0) + + def test_without_previous_sample_gives_none(self): + cur = monitor_model.parse_cpu_times('cpu 1 2 3 4\n') + self.assertIsNone(monitor_model.cpu_percent(None, cur)) + + def test_nonpositive_delta_gives_none(self): + sample = monitor_model.parse_cpu_times('cpu 1 2 3 4\n') + self.assertIsNone(monitor_model.cpu_percent(sample, sample)) + + def test_decreasing_counters_give_none(self): + prev = monitor_model.parse_cpu_times('cpu 5 0 5 100 0 0 0 0\n') + cur = monitor_model.parse_cpu_times('cpu 1 0 1 10 0 0 0 0\n') + self.assertIsNone(monitor_model.cpu_percent(prev, cur)) + + +class MeminfoTests(unittest.TestCase): + def test_parse_meminfo_reads_required_fields(self): + total, available = inline_meminfo(fixture_text('proc_meminfo.txt')) + self.assertEqual((total, available), (16066996, 9000000)) + info = monitor_model.parse_meminfo(fixture_text('proc_meminfo.txt')) + self.assertEqual((info.total_kib, info.available_kib), + (total, available)) + + def test_meminfo_without_available_raises(self): + with self.assertRaises(ValueError): + monitor_model.parse_meminfo('MemTotal: 1024 kB\n') + + def test_meminfo_field_without_value_raises_value_error(self): + # Feldname ohne Wert (leerer Rest nach dem Doppelpunkt) ist ein + # ValueError — kein IndexError, den tick() nicht zur Lücke macht. + with self.assertRaises(ValueError): + monitor_model.parse_meminfo('MemTotal:\nMemAvailable: 1 kB\n') + + def test_percent_matches_independent_fixture_value(self): + text = fixture_text('proc_meminfo.txt') + total, available = inline_meminfo(text) + expected = 100.0 * (total - available) / total + # Anker gegen den Plan: 100 × 7066996/16066996. + self.assertAlmostEqual(expected, 100.0 * 7066996 / 16066996, places=6) + + got = monitor_model.meminfo_percent(monitor_model.parse_meminfo(text)) + self.assertAlmostEqual(got, expected, delta=0.2) + self.assertGreaterEqual(got, 0.0) + self.assertLessEqual(got, 100.0) + + def test_zero_total_gives_none(self): + self.assertIsNone( + monitor_model.meminfo_percent(monitor_model.MemInfo(0, 0))) + + +class PressureTests(unittest.TestCase): + CASES = ( + ('proc_pressure_cpu.txt', 0.52), + ('proc_pressure_memory.txt', 1.25), + ('proc_pressure_io.txt', 2.50), + ) + + def test_some_avg10_matches_independent_fixture_value(self): + for name, expected in self.CASES: + with self.subTest(fixture=name): + text = fixture_text(name) + self.assertAlmostEqual(inline_pressure_some_avg10(text), + expected, delta=0.01) + got = monitor_model.parse_pressure_avg10(text) + self.assertAlmostEqual(got, expected, delta=0.01) + self.assertGreaterEqual(got, 0.0) + self.assertLessEqual(got, 100.0) + + def test_missing_some_line_raises(self): + with self.assertRaises(ValueError): + monitor_model.parse_pressure_avg10( + 'full avg10=0.00 avg60=0.00 avg300=0.00 total=0\n') + + def test_some_line_without_avg10_raises(self): + with self.assertRaises(ValueError): + monitor_model.parse_pressure_avg10('some avg60=0.31 total=1234567\n') + + def test_non_finite_avg10_raises_value_error(self): + # nan/inf sind keine Messwerte → ValueError (der Sampler macht + # daraus eine Lücke statt eines 'nan %'-Samples). + for token in ('nan', 'inf', '-inf'): + with self.subTest(token=token): + with self.assertRaises(ValueError): + monitor_model.parse_pressure_avg10( + 'some avg10=%s avg60=0.00 avg300=0.00 total=0\n' + % token) + + +class ValueSpecTests(unittest.TestCase): + # Bindende D1-Werte: Label, Dezimalen, Toleranz je id. + EXPECTED = { + 'cpu_percent': ('CPU', 1, 0.5), + 'ram_percent': ('RAM', 1, 0.2), + 'psi_cpu_avg10': ('PSI CPU', 2, 0.01), + 'psi_memory_avg10': ('PSI Speicher', 2, 0.01), + 'psi_io_avg10': ('PSI I/O', 2, 0.01), + } + + # Quelle und Formel wörtlich aus VALUE_SPECS — Spec-Drift wird rot. + SOURCES_AND_FORMULAS = { + 'cpu_percent': ( + '/proc/stat', + '100 × Δbusy/Δtotal, busy = total − idle − iowait, ' + 'Delta zweier aufeinanderfolgender Proben', + ), + 'ram_percent': ( + '/proc/meminfo', + '100 × (MemTotal − MemAvailable)/MemTotal', + ), + 'psi_cpu_avg10': ( + '/proc/pressure/cpu', + 'some avg10 unverändert übernommen', + ), + 'psi_memory_avg10': ( + '/proc/pressure/memory', + 'some avg10 unverändert übernommen', + ), + 'psi_io_avg10': ( + '/proc/pressure/io', + 'some avg10 unverändert übernommen', + ), + } + + def test_five_specs_with_unique_ids_in_order(self): + specs = monitor_model.VALUE_SPECS + self.assertEqual(len(specs), 5) + ids = [spec.id for spec in specs] + self.assertEqual(ids, list(self.EXPECTED)) + self.assertEqual(len(set(ids)), 5) + + def test_labels_units_decimals_and_tolerances(self): + for spec in monitor_model.VALUE_SPECS: + label, decimals, tolerance = self.EXPECTED[spec.id] + with self.subTest(spec=spec.id): + self.assertEqual(spec.unit, '%') + self.assertEqual(spec.label, label) + self.assertEqual(spec.decimals, decimals) + self.assertGreater(spec.tolerance, 0.0) + self.assertAlmostEqual(spec.tolerance, tolerance, places=6) + + def test_sources_and_formulas_are_pinned(self): + by_id = {spec.id: spec for spec in monitor_model.VALUE_SPECS} + self.assertEqual(set(by_id), set(self.SOURCES_AND_FORMULAS)) + for value_id, (source, formula) in self.SOURCES_AND_FORMULAS.items(): + with self.subTest(spec=value_id): + self.assertEqual(by_id[value_id].source, source) + self.assertEqual(by_id[value_id].formula, formula) + + def test_fixture_files_exist(self): + for spec in monitor_model.VALUE_SPECS: + with self.subTest(spec=spec.id): + self.assertTrue((FIXTURE_DIR / spec.fixture).is_file(), + 'fehlende Fixture: %s' % spec.fixture) + + +class RingBufferTests(unittest.TestCase): + """Ringpuffer (D3, Task 2): Monotonie, Überlauf, Tupel-Momentaufnahme.""" + + def test_sample_is_a_timestamped_payload_pair(self): + sample = monitor_model.Sample(ts=3.0, payload='wert') + self.assertEqual(sample.ts, 3.0) + self.assertEqual(sample.payload, 'wert') + self.assertEqual(tuple(sample), (3.0, 'wert')) + + def test_first_finite_append_is_accepted(self): + # Erster Append ohne Vergleich; einzige Ausnahme NaN (PR-Review #119, + # test_nan_as_first_timestamp_is_rejected_and_does_not_block). + ring = monitor_model.RingBuffer(3) + self.assertEqual(len(ring), 0) + self.assertEqual(ring.rejected, 0) + self.assertTrue(ring.append(1, 'erst')) + self.assertEqual(len(ring), 1) + + def test_first_append_with_zero_timestamp_is_accepted(self): + # Truthiness-Falle: ts=0 ist ein gültiger erster Zeitstempel. + ring = monitor_model.RingBuffer(3) + self.assertTrue(ring.append(0, 'null')) + self.assertEqual(len(ring), 1) + + def test_overflow_keeps_the_newest_entries_in_order(self): + ring = monitor_model.RingBuffer(3) + for ts in (1, 2, 3, 4, 5): + self.assertTrue(ring.append(ts, 'p%d' % ts)) + self.assertEqual(ring.rejected, 0) # Überlauf ist Annahme, keine Ablehnung + self.assertEqual(len(ring), 3) + self.assertEqual([sample.ts for sample in ring.entries], [3, 4, 5]) + self.assertEqual([sample.payload for sample in ring.entries], + ['p3', 'p4', 'p5']) + + def test_capacity_one_keeps_only_the_newest_entry(self): + ring = monitor_model.RingBuffer(1) + self.assertTrue(ring.append(1, 'alt')) + self.assertTrue(ring.append(2, 'neu')) + self.assertEqual(len(ring), 1) + self.assertEqual(ring.entries, (monitor_model.Sample(2, 'neu'),)) + + def test_same_or_smaller_timestamp_is_rejected_without_mutation(self): + ring = monitor_model.RingBuffer(3) + self.assertTrue(ring.append(5, 'a')) + before_len = len(ring) + before_entries = ring.entries + self.assertFalse(ring.append(5, 'b')) # gleicher ts wie der letzte + self.assertEqual(ring.rejected, 1) + self.assertFalse(ring.append(4, 'c')) # kleinerer ts + self.assertEqual(ring.rejected, 2) + self.assertEqual(len(ring), before_len) + self.assertEqual(ring.entries, before_entries) + + def test_rejected_counter_does_not_block_a_larger_timestamp(self): + ring = monitor_model.RingBuffer(3) + ring.append(5, 'a') + self.assertFalse(ring.append(5, 'b')) + self.assertFalse(ring.append(4, 'c')) + self.assertTrue(ring.append(6, 'd')) + self.assertEqual(ring.rejected, 2) + self.assertEqual([sample.ts for sample in ring.entries], [5, 6]) + + def test_nan_timestamp_is_rejected_and_guard_stays_active(self): + ring = monitor_model.RingBuffer(3) + self.assertTrue(ring.append(1.0, 'a')) + before_len = len(ring) + before_entries = ring.entries + self.assertFalse(ring.append(float('nan'), 'n')) + self.assertEqual(ring.rejected, 1) + self.assertEqual(len(ring), before_len) + self.assertEqual(ring.entries, before_entries) + self.assertTrue(ring.append(2.0, 'b')) + + def test_nan_as_first_timestamp_is_rejected_and_does_not_block(self): + # PR-Review #119: Ein NaN als erster Zeitstempel darf nicht im Puffer + # landen — sonst scheitert jeder spätere Append an `ts > nan` und der + # Wert wäre dauerhaft blockiert. -inf bleibt als Erstappend erlaubt. + ring = monitor_model.RingBuffer(3) + self.assertFalse(ring.append(float('nan'), 'n')) + self.assertEqual(len(ring), 0) + self.assertEqual(ring.rejected, 1) + self.assertTrue(ring.append(1.0, 'a')) + self.assertEqual([sample.ts for sample in ring.entries], [1.0]) + + def test_positive_inf_follows_the_documented_monotonic_contract(self): + # Doku (RingBuffer.append-Docstring + MONITOR.md §2): +inf wird + # angenommen, solange der letzte Eintrag kleiner ist — inf > 1 gilt; + # ein zweiter inf-Append scheitert, weil inf > inf falsch ist. + ring = monitor_model.RingBuffer(3) + self.assertTrue(ring.append(1.0, 'a')) + self.assertTrue(ring.append(float('inf'), 'inf')) + self.assertEqual(len(ring), 2) + self.assertFalse(ring.append(float('inf'), 'nochmals')) + self.assertEqual(ring.rejected, 1) + self.assertEqual([sample.ts for sample in ring.entries], + [1.0, float('inf')]) + + def test_negative_inf_is_rejected_once_the_buffer_has_entries(self): + # Doku: -inf wird bei bestehendem Puffer abgelehnt (nicht monoton + # gegenüber dem letzten Eintrag); nur als erster Append akzeptiert, + # weil der Guard bei leerem Puffer kurzschließt. + ring = monitor_model.RingBuffer(3) + self.assertTrue(ring.append(1.0, 'a')) + before_entries = ring.entries + self.assertFalse(ring.append(float('-inf'), 'minus')) + self.assertEqual(ring.rejected, 1) + self.assertEqual(ring.entries, before_entries) + + empty = monitor_model.RingBuffer(3) + self.assertTrue(empty.append(float('-inf'), 'erster')) + self.assertEqual(len(empty), 1) + + def test_entries_is_a_tuple_snapshot(self): + ring = monitor_model.RingBuffer(3) + ring.append(1, 'a') + entries = ring.entries + self.assertIsInstance(entries, tuple) + self.assertEqual(entries, (monitor_model.Sample(1, 'a'),)) + self.assertIsInstance(ring.entries, tuple) + + def test_payload_is_not_restricted_to_one_type(self): + ring = monitor_model.RingBuffer(2) + ring.append(1, ('value-sample', 37.8)) + ring.append(2, None) + self.assertEqual(ring.entries[0].payload, ('value-sample', 37.8)) + self.assertIsNone(ring.entries[1].payload) + + def test_capacity_below_one_raises_value_error(self): + for capacity in (0, -1, 0.5): + with self.subTest(capacity=capacity): + with self.assertRaises(ValueError): + monitor_model.RingBuffer(capacity) + + def test_non_integer_capacity_raises_value_error(self): + # PR-Review #119: Die Kapazität ist eine Slot-Anzahl. Brüche, ganzzahlige + # Floats und nan/inf würden den Puffer verfälschen (inf/nan: nie + # gekürzt) oder erst in build_series scheitern ([None] * 2.5); + # bool ist eine int-Unterklasse, aber keine Slot-Anzahl. + for capacity in (2.5, 3.0, True, float('inf'), float('nan')): + with self.subTest(capacity=capacity): + with self.assertRaises(ValueError): + monitor_model.RingBuffer(capacity) + + +def inline_ram_percent(text): + """Unabhängige RAM-Nachrechnung in Prozent aus einem meminfo-Text.""" + total, available = inline_meminfo(text) + return 100.0 * (total - available) / total + + +def series_entry(ts, value): + """Sample-Eintrag mit ValueSample-Payload für build_series-Tests.""" + return monitor_model.Sample( + ts=ts, payload=monitor_model.ValueSample(wall=ts, value=value)) + + +class FakeReader(dict): + """Injizierter Reader: Dict {relpath: text}; fehlender Key wirft OSError.""" + + def __init__(self, files): + super().__init__(files) + self.read_paths = [] + + def __call__(self, relpath): + self.read_paths.append(relpath) + if relpath not in self: + raise OSError('Fake-Reader ohne %s' % relpath) + return self[relpath] + + +class FakeClock: + """Deterministische Uhr: liefert die (mono, wall)-Paare der Reihe nach.""" + + def __init__(self, stamps): + self._stamps = list(stamps) + + def __call__(self): + return self._stamps.pop(0) + + +def fixture_reader(): + """Voll bestückter Fake-/proc-Reader: alle fünf VALUE_SPECS-Pfade.""" + return FakeReader({ + 'stat': fixture_text('proc_stat_a.txt'), + 'meminfo': fixture_text('proc_meminfo.txt'), + 'pressure/cpu': fixture_text('proc_pressure_cpu.txt'), + 'pressure/memory': fixture_text('proc_pressure_memory.txt'), + 'pressure/io': fixture_text('proc_pressure_io.txt'), + }) + + +class BuildSeriesTests(unittest.TestCase): + """Serienraster (D4, Task 3): Slots, stale-Lücken, jüngster Eintrag.""" + + def test_gap_sequence_keeps_stale_slots_as_none_not_zero(self): + entries = [series_entry(0, 10.0), series_entry(15, 20.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=5, now=15) + self.assertEqual(len(series), 5) + self.assertEqual(series, (None, None, None, None, 20.0)) + for slot in series[:-1]: + self.assertIsNone(slot) + self.assertNotEqual(slot, 0.0) + + def test_full_grid_keeps_all_five_values_in_order(self): + entries = [series_entry(ts, float(ts)) + for ts in (3, 6, 9, 12, 15)] + series = monitor_model.build_series( + entries, interval=3, capacity=5, now=15) + self.assertEqual(series, (3.0, 6.0, 9.0, 12.0, 15.0)) + self.assertEqual(monitor_model.stale_count(series), 0) + + def test_window_overflow_keeps_only_the_newest_values(self): + entries = [series_entry(ts, float(ts)) for ts in range(3, 31, 3)] + series = monitor_model.build_series( + entries, interval=3, capacity=5, now=30) + self.assertEqual(series, (18.0, 21.0, 24.0, 27.0, 30.0)) + + def test_newest_entry_wins_within_one_slot(self): + entries = [series_entry(3, 10.0), series_entry(4, 20.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=2, now=4) + self.assertEqual(series, (None, 20.0)) + + def test_newest_entry_wins_regardless_of_input_order(self): + entries = [series_entry(4, 20.0), series_entry(3, 10.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=2, now=4) + self.assertEqual(series, (None, 20.0)) + + def test_now_exactly_on_slot_boundary_keeps_the_boundary_slot(self): + entries = [series_entry(15, 20.0)] + boundary = monitor_model.build_series( + entries, interval=3, capacity=5, now=15) + self.assertEqual(boundary[-1], 20.0) + # Knapp davor liegt ts=15 > now (Zukunft) → fällt weg. + before = monitor_model.build_series( + entries, interval=3, capacity=5, now=14.999) + self.assertEqual(before, (None, None, None, None, None)) + + def test_jitter_below_half_interval_does_not_shift_entries(self): + # E2: Raster am letzten Tick verankert. Ticks mit Phase 2.94 s + # (knapp vor der Slotgrenze) und ~0.1 s Verspätung beim zweiten + # Tick dürfen keinen Slot leeren oder doppelt belegen. + entries = [series_entry(2.94, 1.0), series_entry(6.04, 2.0), + series_entry(8.94, 3.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=5, now=8.94) + self.assertEqual(series, (None, None, 1.0, 2.0, 3.0)) + + def test_timer_phase_drift_does_not_create_inner_gaps(self): + # E3: Main-Loop-Stalls verschieben die Phase von timeout_add_seconds + # um +1 s; Abstände 3,3,4,3,3,4,3,3 driften auf 26 s auseinander. + # Gegen ein am letzten Tick verankertes Raster (E2) rutscht ts=6 + # um einen Slot (Summe-Drift >= 1,5 s) -> falsche innere Lücke. + stamps = (0, 3, 6, 10, 13, 16, 20, 23, 26) + entries = [series_entry(ts, float(ts)) for ts in stamps] + series = monitor_model.build_series( + entries, interval=3, capacity=12, now=26) + self.assertEqual( + series, (None, None, None) + tuple(float(ts) for ts in stamps)) + self.assertEqual(monitor_model.stale_count(series), 3) + + def test_real_gap_of_one_and_a_half_intervals_is_exactly_one_stale_slot(self): + # Kontrolle: erst ein echter Abstand >= 1,5 Intervalle lässt einen + # Slot leer; knapp darunter (4,49 s) bleibt das Raster lückenlos. + gap = monitor_model.build_series( + [series_entry(0, 1.0), series_entry(4.5, 2.0)], + interval=3, capacity=5, now=4.5) + self.assertEqual(gap, (None, None, 1.0, None, 2.0)) + no_gap = monitor_model.build_series( + [series_entry(0, 1.0), series_entry(4.49, 2.0)], + interval=3, capacity=5, now=4.49) + self.assertEqual(no_gap, (None, None, None, 1.0, 2.0)) + + def test_entry_one_slot_beyond_the_window_is_dropped(self): + # Fenstergrenze: k = floor(15/3 + 0.5) = 5 == capacity -> fällt weg + # (kein negativer Index, der in den letzten Slot zurückwickelt). + series = monitor_model.build_series( + [series_entry(0, 1.0)], interval=3, capacity=5, now=15) + self.assertEqual(series, (None,) * 5) + + def test_entries_after_now_are_dropped(self): + entries = [series_entry(3, 1.0), series_entry(9, 2.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=3, now=6) + self.assertEqual(series, (None, 1.0, None)) + + def test_non_finite_entry_timestamp_is_skipped_without_raising(self): + entries = [series_entry(6, 1.0), + series_entry(float('inf'), 2.0), + series_entry(float('nan'), 3.0)] + series = monitor_model.build_series( + entries, interval=3, capacity=3, now=6) + self.assertEqual(series, (None, None, 1.0)) + + def test_non_finite_now_yields_full_none_grid(self): + entries = [series_entry(3, 1.0), series_entry(6, 2.0)] + for now in (float('inf'), float('-inf'), float('nan')): + with self.subTest(now=now): + series = monitor_model.build_series( + entries, interval=3, capacity=5, now=now) + self.assertEqual(series, (None,) * 5) + + def test_stale_count_counts_none_slots(self): + self.assertEqual( + monitor_model.stale_count((None, None, None, None, 20.0)), 4) + self.assertEqual(monitor_model.stale_count([None, 1.0, None]), 2) + self.assertEqual(monitor_model.stale_count(()), 0) + + def test_empty_entries_yield_full_none_grid_of_capacity_length(self): + series = monitor_model.build_series( + (), interval=3, capacity=5, now=0) + self.assertEqual(series, (None,) * 5) + self.assertEqual(monitor_model.stale_count(series), 5) + + +class ProcReaderTests(unittest.TestCase): + """ProcReader (D5): Datei-I/O mit injizierbarem root, kein /proc-Zugriff.""" + + def test_read_returns_file_content_below_root(self): + with tempfile.TemporaryDirectory() as tmpdir: + nested = Path(tmpdir) / 'x' + nested.mkdir() + (nested / 'y').write_text('inhalt\n', encoding='utf-8') + reader = monitor_model.ProcReader(root=tmpdir) + self.assertEqual(reader.read('x/y'), 'inhalt\n') + + def test_missing_file_raises_os_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + reader = monitor_model.ProcReader(root=tmpdir) + with self.assertRaises(OSError): + reader.read('fehlt/nicht') + + +class SamplerTests(unittest.TestCase): + """Sampler (D5, Task 3): Injektion, Lücken, CPU-Delta, Serien.""" + + def test_value_sample_pairs_wall_clock_and_value(self): + sample = monitor_model.ValueSample(wall=1000.5, value=43.98) + self.assertEqual((sample.wall, sample.value), (1000.5, 43.98)) + self.assertEqual(tuple(sample), (1000.5, 43.98)) + + def test_defaults_and_configured_interval_capacity_are_readable(self): + default = monitor_model.Sampler(fixture_reader(), FakeClock([])) + self.assertEqual(default.interval, 3.0) + self.assertEqual(default.capacity, 60) + custom = monitor_model.Sampler( + fixture_reader(), FakeClock([]), interval=1.5, capacity=8) + self.assertEqual(custom.interval, 1.5) + self.assertEqual(custom.capacity, 8) + + def test_tick_reads_exactly_the_spec_sources_once(self): + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0)])) + sampler.tick() + self.assertEqual( + reader.read_paths, + ['stat', 'meminfo', 'pressure/cpu', + 'pressure/memory', 'pressure/io']) + + def test_first_tick_reports_cpu_gap_and_reads_the_other_values(self): + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0)])) + sampler.tick() + + self.assertEqual( + sampler.gaps, + {'cpu_percent': 'Delta-Basis fehlt (erster Tick)'}) + self.assertEqual(sampler.samples('cpu_percent'), ()) + self.assertIsNone(sampler.latest('cpu_percent')) + + expected_ram = inline_ram_percent(fixture_text('proc_meminfo.txt')) + # Anker gegen Erratum E1: 100 × 7066996/16066996 = 43,98455… %. + self.assertAlmostEqual( + expected_ram, 100.0 * 7066996 / 16066996, places=6) + ram = sampler.latest('ram_percent') + self.assertAlmostEqual(ram.value, expected_ram, delta=0.2) + self.assertEqual(ram.wall, 1000.0) + + for value_id, expected in (('psi_cpu_avg10', 0.52), + ('psi_memory_avg10', 1.25), + ('psi_io_avg10', 2.50)): + with self.subTest(value_id=value_id): + got = sampler.latest(value_id) + self.assertAlmostEqual(got.value, expected, delta=0.01) + self.assertEqual(got.wall, 1000.0) + + series = sampler.series('ram_percent', now=0.0) + self.assertEqual(len(series), 60) # Default: 60 Slots (D2) + self.assertAlmostEqual(series[-1], expected_ram, delta=0.2) + + def test_second_tick_reports_cpu_delta_and_clears_gaps(self): + stat_a = fixture_text('proc_stat_a.txt') + stat_b = fixture_text('proc_stat_b.txt') + total_a, idle_a, iowait_a = inline_cpu_times(stat_a) + total_b, idle_b, iowait_b = inline_cpu_times(stat_b) + expected = 100.0 * ( + (total_b - idle_b - iowait_b) - (total_a - idle_a - iowait_a) + ) / (total_b - total_a) + # Anker gegen den Plan: Δbusy 376 / Δtotal 996. + self.assertAlmostEqual(expected, 100.0 * 376 / 996, places=6) + + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0), (3.0, 1003.0)])) + sampler.tick() + reader['stat'] = stat_b # Probe a → b zwischen den Ticks + sampler.tick() + + self.assertEqual(sampler.gaps, {}) + cpu = sampler.latest('cpu_percent') + self.assertAlmostEqual(cpu.value, expected, delta=0.5) + self.assertEqual(cpu.wall, 1003.0) + self.assertEqual(len(sampler.samples('cpu_percent')), 1) + # Ring-ts ist mono, nicht wall: ts=3.0 ist bei now=3.0 der letzte Slot. + series = sampler.series('cpu_percent', now=3.0) + self.assertAlmostEqual(series[-1], expected, delta=0.5) + + def test_missing_file_is_a_gap_and_the_others_stay_fresh(self): + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0), (3.0, 1003.0)])) + sampler.tick() + reader['stat'] = fixture_text('proc_stat_b.txt') # CPU-Delta Tick 2 + del reader['pressure/io'] + sampler.tick() + + self.assertTrue(sampler.gaps['psi_io_avg10'].startswith('OSError')) + self.assertEqual( + sampler.gaps, {'psi_io_avg10': 'OSError: pressure/io'}) + for value_id in ('cpu_percent', 'ram_percent', 'psi_cpu_avg10', + 'psi_memory_avg10'): + with self.subTest(value_id=value_id): + self.assertNotIn(value_id, sampler.gaps) + self.assertEqual(sampler.latest(value_id).wall, 1003.0) + # IO behält die Tick-1-Probe, kein neuer Eintrag. + self.assertEqual(len(sampler.samples('psi_io_avg10')), 1) + + def test_broken_meminfo_is_a_value_error_gap(self): + reader = fixture_reader() + reader['meminfo'] = 'MemFree: 2111222 kB\n' # ohne Pflichtfelder + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0)])) + sampler.tick() + + self.assertTrue(sampler.gaps['ram_percent'].startswith('ValueError')) + self.assertEqual( + sampler.gaps, + {'cpu_percent': 'Delta-Basis fehlt (erster Tick)', + 'ram_percent': 'ValueError: meminfo'}) + self.assertIsNone(sampler.latest('ram_percent')) + for value_id, expected in (('psi_cpu_avg10', 0.52), + ('psi_memory_avg10', 1.25), + ('psi_io_avg10', 2.50)): + with self.subTest(value_id=value_id): + self.assertAlmostEqual(sampler.latest(value_id).value, + expected, delta=0.01) + + def test_meminfo_without_usable_total_is_a_named_value_gap(self): + reader = fixture_reader() + # Parsebar (beide Pflichtfelder da), aber total 0 → meminfo_percent None. + reader['meminfo'] = 'MemTotal: 0 kB\nMemAvailable: 0 kB\n' + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0)])) + sampler.tick() + + self.assertEqual( + sampler.gaps['ram_percent'], 'Wert ungueltig: meminfo') + self.assertEqual( + sampler.gaps, + {'cpu_percent': 'Delta-Basis fehlt (erster Tick)', + 'ram_percent': 'Wert ungueltig: meminfo'}) + self.assertIsNone(sampler.latest('ram_percent')) + self.assertEqual(sampler.samples('ram_percent'), ()) + for value_id, expected in (('psi_cpu_avg10', 0.52), + ('psi_memory_avg10', 1.25), + ('psi_io_avg10', 2.50)): + with self.subTest(value_id=value_id): + self.assertAlmostEqual(sampler.latest(value_id).value, + expected, delta=0.01) + + def test_series_after_three_ticks_has_stale_gaps_and_fresh_tails(self): + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0), (3.0, 1003.0), + (6.0, 1006.0)])) + sampler.tick() + reader['stat'] = fixture_text('proc_stat_b.txt') + sampler.tick() + # Dritte Probe mit denselben Deltas wie a→b (CPU bleibt 37,7510). + reader['stat'] = 'cpu 1600 70 320 6200 140 14 28 0 0 0\n' + sampler.tick() + + ram_series = sampler.series('ram_percent') + self.assertEqual(len(ram_series), 60) + ram_values = [sample.value + for sample in sampler.samples('ram_percent')] + self.assertEqual(len(ram_values), 3) + self.assertEqual(ram_series[-3:], tuple(ram_values)) + self.assertTrue(all(slot is None for slot in ram_series[:-3])) + self.assertEqual(sampler.stale_count('ram_percent'), 57) + + cpu_series = sampler.series('cpu_percent') + self.assertEqual(len(cpu_series), 60) + # Tick 1 war die Delta-Basis-Lücke → stale (None), nie 0. + self.assertEqual(sampler.stale_count('cpu_percent'), 58) + self.assertIsNone(cpu_series[-3]) + self.assertIsNotNone(cpu_series[-2]) + self.assertIsNotNone(cpu_series[-1]) + self.assertEqual(len(sampler.samples('cpu_percent')), 2) + + def test_jittering_clock_does_not_create_false_stale_gaps(self): + # E2: Tick-Phase knapp vor der Slotgrenze (2.94 s) mit ~0.1 s + # Verspätung beim zweiten Tick; die drei Proben bleiben in den + # letzten drei Slots, stale zählt nur das leere Restfenster. + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(2.94, 1000.0), (6.04, 1003.1), + (8.94, 1006.0)]), + interval=3.0, capacity=10) + for _ in range(3): + sampler.tick() + ram_series = sampler.series('ram_percent') + self.assertEqual(len(ram_series), 10) + self.assertTrue(all(slot is not None for slot in ram_series[-3:])) + self.assertEqual(sampler.stale_count('ram_percent'), 10 - 3) + + def test_timer_phase_drift_sequence_has_no_false_stale_gaps(self): + # E3 über den Sampler: Tick-Abstände 3,3,4,3,3,4,3,3 (Phasendrift + # durch Main-Loop-Stalls) dürfen keine innere Lücke erzeugen. + stamps = (0.0, 3.0, 6.0, 10.0, 13.0, 16.0, 20.0, 23.0, 26.0) + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(mono, 1000.0 + mono) for mono in stamps]), + interval=3.0, capacity=12) + for _ in stamps: + sampler.tick() + ram_series = sampler.series('ram_percent') + self.assertEqual(len(ram_series), 12) + self.assertTrue(all(slot is not None for slot in ram_series[-9:])) + self.assertEqual(sampler.stale_count('ram_percent'), 12 - 9) + + def test_series_before_first_tick_is_a_full_none_grid(self): + sampler = monitor_model.Sampler(fixture_reader(), FakeClock([])) + series = sampler.series('ram_percent') + self.assertEqual(series, (None,) * 60) + self.assertEqual(sampler.stale_count('ram_percent'), 60) + self.assertIsNone(sampler.latest('ram_percent')) + self.assertEqual(sampler.samples('ram_percent'), ()) + + def test_repeated_clock_is_rejected_as_non_monotonic_gap(self): + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0), (0.0, 1003.0)])) + sampler.tick() + sampler.tick() + + self.assertEqual(sampler.gaps['ram_percent'], 'Zeit nicht monoton') + self.assertEqual(len(sampler.samples('ram_percent')), 1) + # CPU: zweite Probe unverändert → Δtotal 0 → 'Delta ungueltig'. + self.assertEqual(sampler.gaps['cpu_percent'], 'Delta ungueltig') + + def test_nan_first_clock_is_a_gap_and_does_not_block_later_ticks(self): + # PR-Review #119: liefert die Uhr beim ersten Tick NaN, wird das zur + # Lücke 'Zeit nicht monoton'; der nächste gültige Tick misst normal. + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(float('nan'), 1000.0), (3.0, 1003.0)])) + sampler.tick() + self.assertEqual(sampler.gaps['ram_percent'], 'Zeit nicht monoton') + self.assertEqual(sampler.samples('ram_percent'), ()) + + sampler.tick() + self.assertNotIn('ram_percent', sampler.gaps) + self.assertEqual(len(sampler.samples('ram_percent')), 1) + self.assertEqual(sampler.stale_count('ram_percent'), + sampler.capacity - 1) + + def test_non_integer_capacity_is_rejected_at_construction(self): + # Ohne Prüfung im RingBuffer würde Sampler(capacity=2.5) erst beim + # Zeichnen in build_series mit TypeError scheitern. + with self.assertRaises(ValueError): + monitor_model.Sampler(fixture_reader(), FakeClock([]), + capacity=2.5) + + def test_stat_read_failure_keeps_cpu_a_gap_without_storing_a_basis(self): + # Der CPU-Zweig ist der einzige mit zustandstragendem Delta: Liest + # self._read (monitor_model.py:371) für stat schon bei Tick 1 nicht, + # wirft er VOR der Zuweisung self._previous_cpu = current (:375) — + # es wird nie eine Delta-Basis gespeichert. Tick 2 mit wieder + # lesbarem stat muss deshalb erneut 'Delta-Basis fehlt' melden + # statt aus einer nie vorhandenen Basis zu rechnen. + reader = fixture_reader() + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0), (3.0, 1003.0)])) + del reader['stat'] + sampler.tick() + self.assertEqual(sampler.gaps['cpu_percent'], 'OSError: stat') + self.assertIsNone(sampler.latest('cpu_percent')) + + reader['stat'] = fixture_text('proc_stat_a.txt') + sampler.tick() + self.assertEqual(sampler.gaps['cpu_percent'], + 'Delta-Basis fehlt (erster Tick)') + self.assertIsNone(sampler.latest('cpu_percent')) + + def test_truncated_stat_is_a_value_error_gap(self): + # ValueError-Zweig derselben Naht: proc_stat_truncated.txt wirft + # beim Parsen, die Meldung folgt dem gaps-Format '%s: %s' (:354). + reader = fixture_reader() + reader['stat'] = fixture_text('proc_stat_truncated.txt') + sampler = monitor_model.Sampler( + reader, FakeClock([(0.0, 1000.0)])) + sampler.tick() + self.assertEqual(sampler.gaps['cpu_percent'], 'ValueError: stat') + + def test_custom_interval_and_capacity_flow_into_the_series_grid(self): + # Serie über den Sampler: Werden interval/capacity im Konstruktor + # gesetzt, müssen build_series-Aufrufe in Sampler.series (:408-410) + # diese Werte weiterreichen — ein hardcodiertes 3.0/60 dort würde + # die Slots hier falsch rasterieren. + reader = fixture_reader() + stamps = [(0.0, 1000.0), (1.5, 1001.5), (3.0, 1003.0), (4.5, 1004.5)] + sampler = monitor_model.Sampler( + reader, FakeClock(stamps), interval=1.5, capacity=4) + for _ in stamps: + sampler.tick() + + series = sampler.series('ram_percent', now=4.5) + self.assertEqual(len(series), 4) + self.assertEqual(sampler.stale_count('ram_percent', now=4.5), 0) + ram_values = [sample.value + for sample in sampler.samples('ram_percent')] + self.assertEqual(series, tuple(ram_values)) + + +def value_spec(value_id): + """ValueSpec per id aus VALUE_SPECS (Reihenfolge prüft die Spec-Klasse).""" + return next(spec for spec in monitor_model.VALUE_SPECS if spec.id == value_id) + + +class FormatterTests(unittest.TestCase): + """Formatierer (Task 4): Anzeige, Caption, Drill-down — exakte Strings. + + Die Zeitfälle laufen mit `TZ=UTC` (setUp/tearDown, `time.tzset()`); + Wall 1000.0 → `00:16:40`, 1003.0 → `00:16:43`. + """ + + def setUp(self): + self._old_tz = os.environ.get('TZ') + os.environ['TZ'] = 'UTC' + time.tzset() + + def tearDown(self): + if self._old_tz is None: + os.environ.pop('TZ', None) + else: + os.environ['TZ'] = self._old_tz + time.tzset() + + def test_format_value_uses_spec_decimals_and_unit(self): + # Punkt als Dezimaltrenner; Anzeige-Rundung der Plan-Anker. + self.assertEqual( + monitor_model.format_value( + value_spec('cpu_percent'), 100.0 * 376 / 996), '37.8 %') + self.assertEqual( + monitor_model.format_value( + value_spec('ram_percent'), 100.0 * 7066996 / 16066996), + '44.0 %') + self.assertEqual( + monitor_model.format_value(value_spec('psi_cpu_avg10'), 0.52), + '0.52 %') + + def test_format_value_pins_two_decimals_for_psi(self): + # Feste Dezimalen: 2.50 bleibt '2.50 %' (kein round()/str()-Kürzen). + self.assertEqual( + monitor_model.format_value(value_spec('psi_io_avg10'), 2.50), + '2.50 %') + + def test_format_value_none_is_stale_never_zero(self): + cpu = value_spec('cpu_percent') + self.assertEqual(monitor_model.format_value(cpu, None), 'stale') + self.assertNotEqual(monitor_model.format_value(cpu, None), '0.0 %') + self.assertNotEqual(monitor_model.format_value(cpu, None), '0 %') + + def test_format_caption_pins_source_unit_and_wall_clock(self): + self.assertEqual( + monitor_model.format_caption(value_spec('cpu_percent'), 1000.0, 0), + 'Quelle: /proc/stat · Einheit: % · Messzeit: 00:16:40') + + def test_wall_clock_is_shown_in_local_time_not_utc(self): + # Nicht-UTC-Zone gepinnt (UTC+2): localtime, nicht gmtime. tearDown + # setzt TZ danach wieder auf den Ausgangszustand zurück. + os.environ['TZ'] = 'XXX-02' + time.tzset() + self.assertEqual( + monitor_model.format_caption(value_spec('cpu_percent'), 1000.0, 0), + 'Quelle: /proc/stat · Einheit: % · Messzeit: 02:16:40') + drilldown = monitor_model.format_drilldown( + value_spec('cpu_percent'), + [monitor_model.ValueSample(wall=1000.0, value=10.0)], 0) + self.assertEqual(drilldown.splitlines()[-1], '02:16:40 10.0 %') + + def test_format_caption_appends_stale_suffix_only_when_stale(self): + cpu = value_spec('cpu_percent') + self.assertEqual( + monitor_model.format_caption(cpu, 1000.0, 57), + 'Quelle: /proc/stat · Einheit: % · Messzeit: 00:16:40 · stale: 57') + self.assertEqual( + monitor_model.format_caption(cpu, 1000.0, 0), + 'Quelle: /proc/stat · Einheit: % · Messzeit: 00:16:40') + + def test_format_caption_without_sample_shows_dash_and_stale(self): + self.assertEqual( + monitor_model.format_caption(value_spec('cpu_percent'), None, 60), + 'Quelle: /proc/stat · Einheit: % · Messzeit: – · stale: 60') + + def test_format_drilldown_pins_full_multiline_text(self): + samples = [monitor_model.ValueSample(wall=1000.0, value=10.0), + monitor_model.ValueSample(wall=1003.0, value=20.0)] + self.assertEqual( + monitor_model.format_drilldown( + value_spec('cpu_percent'), samples, 58), + 'CPU: 20.0 %\n' + 'Quelle: /proc/stat\n' + 'Formel: 100 × Δbusy/Δtotal, busy = total − idle − iowait, ' + 'Delta zweier aufeinanderfolgender Proben\n' + 'Toleranz: ±0.5 pp\n' + 'stale: 58\n' + '00:16:40 10.0 %\n' + '00:16:43 20.0 %') + + def test_format_drilldown_without_samples_has_no_sample_lines(self): + lines = monitor_model.format_drilldown( + value_spec('cpu_percent'), [], 60).splitlines() + self.assertEqual(lines, [ + 'CPU: stale', + 'Quelle: /proc/stat', + 'Formel: 100 × Δbusy/Δtotal, busy = total − idle − iowait, ' + 'Delta zweier aufeinanderfolgender Proben', + 'Toleranz: ±0.5 pp', + 'stale: 60', + ]) + + def test_format_drilldown_keeps_only_the_last_ten_samples(self): + samples = [monitor_model.ValueSample(wall=1000.0 + 3 * index, + value=10.0 + index) + for index in range(12)] + lines = monitor_model.format_drilldown( + value_spec('cpu_percent'), samples, 48).splitlines() + self.assertEqual(len(lines), 5 + 10) + self.assertEqual(lines[5], '00:16:46 12.0 %') # ältestes der letzten 10 + self.assertEqual(lines[-1], '00:17:13 21.0 %') # jüngstes + + +if __name__ == '__main__': + unittest.main() diff --git a/prototype/gtk/tests/test_shell_layout.py b/prototype/gtk/tests/test_shell_layout.py new file mode 100644 index 00000000..36035a73 --- /dev/null +++ b/prototype/gtk/tests/test_shell_layout.py @@ -0,0 +1,81 @@ +"""Layout-Gate der GTK-Shell: rechte Detail-Leiste bleibt im Fenster. + +Regression der vorbestehenden „gequetschten“ Detail-Leiste (Triage vor #97, +MONITOR.md §7): `Gtk.Paned` liegt im Inhaltsbereich der +`Adw.NavigationSplitView` (Fenster minus Nav-Sidebar), `PANE_POSITION` gilt +ab dessen linker Kante. Mit dem GTK4-Default `shrink-end-child=TRUE` +ignoriert der Trenner die Mindestbreite der Leiste; GTK alloziert sie zwar +mit ihren 280 px, schiebt sie aber über den rechten Rand hinaus, wo der +Paned sie abschneidet (sichtbar blieben ~60 px). + +Braucht GTK 4 + libadwaita und ein Display; ohne beides (CI: reines Python, +.github/workflows/build.yml) wird die Klasse übersprungen. + +Läuft mit: python3 -m unittest discover -s prototype/gtk/tests (aus dem +Repo-Root). +""" +import sys +import unittest +from pathlib import Path + +GTK_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(GTK_DIR)) + +try: + import gi + gi.require_version('Gtk', '4.0') + gi.require_version('Gdk', '4.0') + gi.require_version('Adw', '1') + from gi.repository import Adw, Gdk, Gtk + # init_check() meldet beim zweiten Aufruf TRUE, auch wenn kein Display + # aufging (Init-Flag steht schon) — ohne Display segfaultet dann die + # Allokation. Maßgeblich ist daher das Default-Display. + GTK_READY = Gtk.init_check() and Gdk.Display.get_default() is not None +except (ImportError, ValueError): + GTK_READY = False + + +def paned_width(window_width): + """Breite des Paned = Fenster minus Nav-Sidebar (Adw-Defaults der Shell).""" + nav = Adw.NavigationSplitView() + sidebar = min(max(window_width * nav.get_sidebar_width_fraction(), + nav.get_min_sidebar_width()), + nav.get_max_sidebar_width()) + return window_width - round(sidebar) + + +@unittest.skipUnless(GTK_READY, 'GTK 4/libadwaita oder Display fehlt') +class DetailPaneTests(unittest.TestCase): + def setUp(self): + import mla_app + self.app = mla_app + self.detail = Gtk.Box() + self.detail.set_size_request(mla_app.DETAILS_MIN_WIDTH, -1) + self.pane = mla_app.build_detail_pane(Gtk.Box(), self.detail) + + def assert_detail_inside(self, width): + self.pane.allocate(width, 700, -1, None) + found, bounds = self.detail.compute_bounds(self.pane) + self.assertTrue(found) + right = bounds.get_x() + bounds.get_width() + self.assertGreaterEqual(bounds.get_width(), self.app.DETAILS_MIN_WIDTH) + self.assertLessEqual( + right, width, + f'Detail-Leiste ragt bei Paned-Breite {width} px bis {right:.0f} px ' + f'(sichtbar {width - bounds.get_x():.0f} px)') + + def test_default_window_shows_full_detail_pane(self): + """Fenster 1200 → Paned 920 px (Sonde X11: Trenner 850, sichtbar 69 px).""" + self.assertEqual(paned_width(self.app.WINDOW_DEFAULT[0]), 920) + self.assert_detail_inside(paned_width(self.app.WINDOW_DEFAULT[0])) + + def test_resize_keeps_detail_pane_inside(self): + """Compositor-/Nutzer-Resize skaliert den Trenner proportional + (Sonde Wayland: 954-px-Fenster, Trenner 661, sichtbar 54 px).""" + for window in (self.app.WINDOW_DEFAULT[0], 954, 900, 1600): + with self.subTest(window=window): + self.assert_detail_inside(paned_width(window)) + + +if __name__ == '__main__': + unittest.main() diff --git a/prototype/gtk/tests/test_tokens.py b/prototype/gtk/tests/test_tokens.py index d2e188c1..9a93feba 100644 --- a/prototype/gtk/tests/test_tokens.py +++ b/prototype/gtk/tests/test_tokens.py @@ -9,8 +9,8 @@ eine bekannte schlechte Paarung abweisen und Schwarz/Weiß mit 21:1 bestehen. focusRing wird NICH als 3:1 behauptet, sondern unter 3:1 gepinnt (dokumentierte Schwäche, TOKENS.md §5). -3. Hardcode-Gate: keine Hex-/rgb()-Farben in mla_app.py (Regex muss an - einem Known-Bad-Snippet anschlagen). +3. Hardcode-Gate: keine Hex-/rgb()-Farben in mla_app.py, monitor_ui.py und + monitor_model.py (Regex muss an einem Known-Bad-Snippet anschlagen). Läuft mit: python3 -m unittest discover -s prototype/gtk/tests (aus dem Repo-Root; auch als CI-Schritt in .github/workflows/build.yml). @@ -24,6 +24,10 @@ TOKENS_DARK_CSS = GTK_DIR / 'tokens-dark.css' TOKENS_MD = GTK_DIR.parent.parent / 'docs' / 'mla-next' / 'TOKENS.md' APP_PY = GTK_DIR / 'mla_app.py' +MONITOR_UI_PY = GTK_DIR / 'monitor_ui.py' +MONITOR_MODEL_PY = GTK_DIR / 'monitor_model.py' +# Hardcode-Gate (Task 4, #96): alle Python-Dateien der GTK-Shell im Scan. +SCANNED_PY = (APP_PY, MONITOR_UI_PY, MONITOR_MODEL_PY) DEFINE_RE = re.compile(r'^\s*@define-color\s+([A-Za-z0-9-]+)\s+([^;]+);') DOC_ROW_RE = re.compile( @@ -207,10 +211,11 @@ class HardcodeTests(unittest.TestCase): RGB_RE = re.compile(r'\brgba?\(') def test_no_color_literals_in_app(self): - source = APP_PY.read_text(encoding='utf-8') - for pattern in (self.HEX_RE, self.RGB_RE): - with self.subTest(pattern=pattern.pattern): - self.assertEqual(pattern.findall(source), []) + for path in SCANNED_PY: + source = path.read_text(encoding='utf-8') + for pattern in (self.HEX_RE, self.RGB_RE): + with self.subTest(file=path.name, pattern=pattern.pattern): + self.assertEqual(pattern.findall(source), []) def test_gate_regex_flags_known_bad_snippet(self): # RED-Beleg: das Regex-Gate ist nicht leer — es muss #B8860B in einem diff --git a/prototype/gtk/tokens.css b/prototype/gtk/tokens.css index 52027c59..f72e8431 100644 --- a/prototype/gtk/tokens.css +++ b/prototype/gtk/tokens.css @@ -111,6 +111,12 @@ window { color: @text; } +/* Drill-down des Monitors: Monospace für die Zeit-/Wert-Spalten + * (fontMono, TOKENS.md §2) — farbfrei, Textfarbe bleibt @text. */ +.mla-drilldown { + font-family: monospace; +} + /* Status-Chips: Radius pill, Hairline-Border; stale per gestrichelter * Border strukturell von ok getrennt (TOKENS.md §4). */ .mla-chip { diff --git a/test/fixtures/README.md b/test/fixtures/README.md index 28a8641d..e3b9a2d7 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -95,6 +95,39 @@ nicht dazu, sie ist persistent pro Installation und wurde nachträglich geschwärzt, siehe Liste oben) sowie generische Pfade unter `/storage` (VM-Disk-Images ohne Personenbezug). +## Synthetische /proc-Snapshots (`proc_*.txt`) + +Seit #96 (GTK-Systemmonitor) liegt hier eine eigene Familie synthetischer +Snapshots: `/proc`-Ausgaben für die fünf Monitorwerte der Wertespec +(`docs/mla-next/MONITOR.md` §1). Sie sind **von Hand gebaut — bewusst kein +Capture**, im Kontrast zu den echten `zorin_*`-Ausgaben: nur so sind die +Deltas zwischen den Proben exakt bekannt, und die Parser-Tests +(`prototype/gtk/tests/test_monitor_model.py`) können ihre Erwartungswerte +unabhängig aus den Fixture-Texten nachrechnen. Es sind keine echten +Hostwerte, Namen oder IPs enthalten. + +| Datei | Monitorwert | Bekannter Inhalt / Delta | +|---|---|---| +| `proc_stat_a.txt` | `cpu_percent` (Basis) | erste von zwei Proben desselben Paares | +| `proc_stat_b.txt` | `cpu_percent` (jünger) | Δtotal 996, Δbusy 376 → `100 × 376/996 = 37,7510… %` | +| `proc_stat_truncated.txt` | — | `cpu `-Zeile mit nur zwei Feldern → Parser wirft `ValueError` | +| `proc_meminfo.txt` | `ram_percent` | MemTotal 16066996 kB, MemAvailable 9000000 kB → `100 × 7066996/16066996 = 43,98455… %` | +| `proc_pressure_cpu.txt` | `psi_cpu_avg10` | `some avg10` = 0.52 | +| `proc_pressure_memory.txt` | `psi_memory_avg10` | `some avg10` = 1.25 | +| `proc_pressure_io.txt` | `psi_io_avg10` | `some avg10` = 2.50 | + +**Flache Lage (bewusst):** Die Dateien liegen direkt in `test/fixtures/`, +nicht in einem Unterordner. Der Leak-Check +(`additional/python/tests/test_fixture_leak_check.py`) iteriert das +Verzeichnis mit `os.listdir` — **nicht rekursiv** — und prüft nur `*.txt`; +flach abgelegte Dateien fallen damit automatisch unter den Check. Ein +Verschieben in Unterordner würde sie stillschweigend aus dem Check nehmen. + +**Leak-Disziplin:** Für die `proc_*.txt` gelten dieselben Regeln wie für +echte Captures (keine IPs, keine echten `/home`-/`/media`-Namen, keine +Domains, keine `host:port`-Paare). Die Werte sind erfunden, aber vor dem +Einchecken selbst durchsehen — der Check ist heuristisch (siehe unten). + ## Leak-Check (heuristische Absicherung) `additional/python/tests/test_fixture_leak_check.py` läuft mit der @@ -169,3 +202,10 @@ Lese-Konvention; in `prototype/gtk/` existiert dazu bewusst noch kein Code. | `uptime_min_wording.txt` | synthetisch | `up 42 min` → Wortlaut „min" | | `uptime_days.txt` | synthetisch | `up 12 days, 3:21` → Tages-Ausgabe | | `free_no_swap.txt` | synthetisch | `Swap:`-Zeile mit Nullen → `hasSwap == false` | +| `proc_stat_a.txt` | synthetisch | /proc/stat-Snapshot, Basisprobe der CPU-Delta-Rechnung | +| `proc_stat_b.txt` | synthetisch | /proc/stat-Snapshot, jüngere Probe (Δ → 37,7510 %) | +| `proc_stat_truncated.txt` | synthetisch | zu kurze `cpu `-Zeile → ValueError-Pfad | +| `proc_meminfo.txt` | synthetisch | /proc/meminfo-Snapshot (RAM 43,98455 %) | +| `proc_pressure_cpu.txt` | synthetisch | /proc/pressure/cpu, `some avg10=0.52` | +| `proc_pressure_memory.txt` | synthetisch | /proc/pressure/memory, `some avg10=1.25` | +| `proc_pressure_io.txt` | synthetisch | /proc/pressure/io, `some avg10=2.50` | diff --git a/test/fixtures/proc_meminfo.txt b/test/fixtures/proc_meminfo.txt new file mode 100644 index 00000000..592d0830 --- /dev/null +++ b/test/fixtures/proc_meminfo.txt @@ -0,0 +1,8 @@ +MemTotal: 16066996 kB +MemFree: 2111222 kB +MemAvailable: 9000000 kB +Buffers: 345678 kB +Cached: 4567890 kB +SwapCached: 0 kB +SwapTotal: 7999484 kB +SwapFree: 7210122 kB diff --git a/test/fixtures/proc_pressure_cpu.txt b/test/fixtures/proc_pressure_cpu.txt new file mode 100644 index 00000000..eb2145ab --- /dev/null +++ b/test/fixtures/proc_pressure_cpu.txt @@ -0,0 +1,2 @@ +some avg10=0.52 avg60=0.31 avg300=0.25 total=1234567 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 diff --git a/test/fixtures/proc_pressure_io.txt b/test/fixtures/proc_pressure_io.txt new file mode 100644 index 00000000..6693e760 --- /dev/null +++ b/test/fixtures/proc_pressure_io.txt @@ -0,0 +1,2 @@ +some avg10=2.50 avg60=1.75 avg300=1.20 total=2345678 +full avg10=1.00 avg60=0.90 avg300=0.80 total=765432 diff --git a/test/fixtures/proc_pressure_memory.txt b/test/fixtures/proc_pressure_memory.txt new file mode 100644 index 00000000..94039205 --- /dev/null +++ b/test/fixtures/proc_pressure_memory.txt @@ -0,0 +1,2 @@ +some avg10=1.25 avg60=0.80 avg300=0.60 total=7654321 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 diff --git a/test/fixtures/proc_stat_a.txt b/test/fixtures/proc_stat_a.txt new file mode 100644 index 00000000..6d0aec14 --- /dev/null +++ b/test/fixtures/proc_stat_a.txt @@ -0,0 +1,10 @@ +cpu 1000 50 200 5000 100 10 20 0 0 0 +cpu0 400 20 80 2000 40 4 8 0 0 0 +cpu1 300 15 60 1500 30 3 6 0 0 0 +cpu2 300 15 60 1500 30 3 6 0 0 0 +intr 123456 0 0 0 +ctxt 987654 +btime 1700000000 +processes 4242 +procs_running 1 +procs_blocked 0 diff --git a/test/fixtures/proc_stat_b.txt b/test/fixtures/proc_stat_b.txt new file mode 100644 index 00000000..cc3c2b8e --- /dev/null +++ b/test/fixtures/proc_stat_b.txt @@ -0,0 +1,10 @@ +cpu 1300 60 260 5600 120 12 24 0 0 0 +cpu0 520 24 104 2240 48 5 10 0 0 0 +cpu1 390 18 78 1680 36 3 7 0 0 0 +cpu2 390 18 78 1680 36 4 7 0 0 0 +intr 124000 0 0 0 +ctxt 988000 +btime 1700000000 +processes 4300 +procs_running 1 +procs_blocked 0 diff --git a/test/fixtures/proc_stat_truncated.txt b/test/fixtures/proc_stat_truncated.txt new file mode 100644 index 00000000..415cff3f --- /dev/null +++ b/test/fixtures/proc_stat_truncated.txt @@ -0,0 +1,2 @@ +cpu 1 2 +ctxt 42