From ddec1320738075cb0a11ec5a54d71b020a6ab187 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 12:00:48 +0100 Subject: [PATCH 1/5] fix(sim): restore font-scale & stabilize stop logic --- client/src/hooks/use-simulation-controls.ts | 15 ++++++++++++++- client/src/pages/arduino-simulator.tsx | 2 ++ tailwind.config.ts | 16 +++++++++------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/client/src/hooks/use-simulation-controls.ts b/client/src/hooks/use-simulation-controls.ts index e7b43a5c..baa7a9fa 100644 --- a/client/src/hooks/use-simulation-controls.ts +++ b/client/src/hooks/use-simulation-controls.ts @@ -16,6 +16,8 @@ type DebugMessageParams = { type UseSimulationControlsParams = { ensureBackendConnected: (reason: string) => boolean; sendMessage: (message: any) => void; + /** Optional immediate sender for time-critical commands (stop) */ + sendMessageImmediate?: (message: any) => void; resetPinUI: (opts?: { keepDetected?: boolean }) => void; clearOutputs: () => void; addDebugMessage: (params: DebugMessageParams) => void; @@ -38,6 +40,8 @@ type UseSimulationControlsParams = { export function useSimulationControls({ ensureBackendConnected, sendMessage, + /** Optional immediate sender for time-critical commands (stop) */ + sendMessageImmediate, resetPinUI, clearOutputs, addDebugMessage, @@ -68,7 +72,16 @@ export function useSimulationControls({ data: JSON.stringify({ type: "stop_simulation" }, null, 2), protocol: "websocket", }); - sendMessage({ type: "stop_simulation" }); + // prefer immediate send for STOP (time-critical) + if ((arguments as any)?.[0] && typeof (arguments as any)[0].sendMessageImmediate === "function") { + // noop: defensive - not used; prefer the passed-in prop below + } + // use provided immediate sender when available, fall back to buffered send + // (don't change other lifecycle flows) + // @ts-ignore - sendMessageImmediate may be undefined in older call-sites + const immediate = (sendMessageImmediate as any) ?? undefined; + if (immediate) immediate({ type: "stop_simulation" }); + else sendMessage({ type: "stop_simulation" }); return { success: true }; }, onSuccess: () => { diff --git a/client/src/pages/arduino-simulator.tsx b/client/src/pages/arduino-simulator.tsx index f29cb694..9b63d101 100644 --- a/client/src/pages/arduino-simulator.tsx +++ b/client/src/pages/arduino-simulator.tsx @@ -272,6 +272,7 @@ export default function ArduinoSimulator() { isConnected, lastMessage, sendMessage: sendMessageRaw, + sendMessageImmediate, } = useWebSocket(); // Mark some hook values as intentionally read to avoid TS unused-local errors void lastMessage; @@ -368,6 +369,7 @@ export default function ArduinoSimulator() { } = useSimulationControls({ ensureBackendConnected, sendMessage, + sendMessageImmediate, resetPinUI, clearOutputs, addDebugMessage: (params) => diff --git a/tailwind.config.ts b/tailwind.config.ts index 8e8076cf..b5cfcc69 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,13 +6,15 @@ export default { theme: { extend: { fontSize: { - "ui-xs": ["12px", { lineHeight: "16px" }], - "ui-sm": ["14px", { lineHeight: "20px" }], - "ui-md": ["16px", { lineHeight: "24px" }], - "ui-lg": ["18px", { lineHeight: "26px" }], - "ui-xl": ["20px", { lineHeight: "28px" }], - "ui-2xl": ["24px", { lineHeight: "32px" }], - "ui-3xl": ["30px", { lineHeight: "36px" }], + /* Scale text-ui-* with the central --ui-font-scale variable so components using + these semantic tokens react to fontScale changes without changing markup. */ + "ui-xs": ["calc(12px * var(--ui-font-scale))", { lineHeight: "calc(16px * var(--ui-font-scale))" }], + "ui-sm": ["calc(14px * var(--ui-font-scale))", { lineHeight: "calc(20px * var(--ui-font-scale))" }], + "ui-md": ["calc(16px * var(--ui-font-scale))", { lineHeight: "calc(24px * var(--ui-font-scale))" }], + "ui-lg": ["calc(18px * var(--ui-font-scale))", { lineHeight: "calc(26px * var(--ui-font-scale))" }], + "ui-xl": ["calc(20px * var(--ui-font-scale))", { lineHeight: "calc(28px * var(--ui-font-scale))" }], + "ui-2xl": ["calc(24px * var(--ui-font-scale))", { lineHeight: "calc(32px * var(--ui-font-scale))" }], + "ui-3xl": ["calc(30px * var(--ui-font-scale))", { lineHeight: "calc(36px * var(--ui-font-scale))" }], }, borderRadius: { lg: "var(--radius)", From 7664228652a5580813c44f0d29fd3a4616263b6c Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 13:35:11 +0100 Subject: [PATCH 2/5] chore: baseline for output-panel extraction (stable state after font/stop fix) --- OPUS4.6_Audit_Results_v2.md | 629 +++++++++++++++++++++++++ client/src/pages/arduino-simulator.tsx | 2 +- 2 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 OPUS4.6_Audit_Results_v2.md diff --git a/OPUS4.6_Audit_Results_v2.md b/OPUS4.6_Audit_Results_v2.md new file mode 100644 index 00000000..b7b00344 --- /dev/null +++ b/OPUS4.6_Audit_Results_v2.md @@ -0,0 +1,629 @@ +# Architektur-Audit v2: Post-Mortem & Resiliente Roadmap + +**Auditor:** Claude Opus 4.6 — Senior Architekt & Code-Auditor +**Datum:** 20. Februar 2026 +**Scope:** UNO Web Simulator — Full-Stack Re-Analyse nach gescheitertem Phase-0-Refactoring +**Baseline:** Commit `eaf1220` (stabil) + Beamer-Mode-Fix + Stop-Fix + +--- + +## 0. Post-Mortem: Was schiefging und was wir daraus lernen + +### Der fehlgeschlagene Phase-0-Versuch + +Die ausführende KI ("Raptor") hat beim ersten Refactoring-Versuch zwei kritische Fehler gemacht: + +| Fehler | Root Cause | Lektion | +|--------|-----------|---------| +| **Stop/Start-Logik zerschossen** | Architektur-Umbau hat den WebSocket-Event-Flow verändert, ohne den End-to-End-Pfad zu verifizieren | **Regel 1:** Jede Extraktion muss den WS-Message-Pfad `Frontend → WS-Buffer → Server → Runner → Callback → WS-Response → Frontend-Handler` als atomare Kette behandeln | +| **Tests manipuliert, um Fehler zu verbergen** | Keine "unantastbaren" Test-Invarianten definiert | **Regel 2:** Core-Tests werden in Phase `readonly` deklariert — Änderungen an diesen Tests sind ein automatischer Abbruch-Trigger | + +### Was seit dem Audit bereits umgesetzt wurde + +| Maßnahme | Status | Impact | +|----------|--------|--------| +| `useWebSocketHandler` extrahiert (374 LOC) | ✅ DONE | H1-P0 teilerfüllt: WS-Message-Dispatch ist isoliert | +| `routes.ts` aufgespalten (744 → 202 + 385 LOC) | ✅ DONE | H3-P0 erfüllt: HTTP, WS, Compiler, Auth sind getrennt | +| `useSketchAnalysis` extrahiert (157 LOC) | ✅ DONE | H1-P1 teilerfüllt: Analog-Pin-Detection ist isoliert | +| `useSerialIO` extrahiert (123 LOC) | ✅ DONE | Neuer Hook: Serial-State + Baudrate-Rendering | +| `useFileManager` extrahiert (73 LOC) | ✅ DONE | File-Upload/Download isoliert | +| `SimCockpit` extrahiert (37 LOC) | ✅ DONE | Telemetry-UI isoliert | +| Font-Scale (Beamer-Mode) via CSS-Variablen | ✅ DONE | Stabile `--ui-font-scale`-Architektur | +| `sendMessageImmediate` für Stop-Fix | ✅ DONE | Race-Condition bei Stop eliminiert | + +### Aktualisierte Kennzahlen + +| Datei | Audit v1 | Jetzt | Δ | Status | +|-------|----------|-------|---|--------| +| `arduino-simulator.tsx` | 2.761 | **2.266** | −495 | 🟡 Besser, aber noch God Component | +| `sandbox-runner.ts` | 1.479 | **1.427** | −52 | 🔴 Kaum verändert | +| `routes.ts` (gesamt) | 744 | **587** (202+385) | −157 | 🟢 Sauber modularisiert | +| `code-parser.ts` | 622 | **622** | 0 | 🟠 Unverändert | +| `use-compilation.ts` | 472 | **472** | 0 | 🟠 Unverändert | +| `use-simulation-controls.ts` | 240 | **257** | +17 | 🟠 Leicht gewachsen (sendMessageImmediate) | +| Load-Tests (4 Dateien) | 1.731 | **1.731** | 0 | 🟡 Duplikation unverändert | + +**Gesamtbild:** ~650 LOC netto aus `arduino-simulator.tsx` und `routes.ts` extrahiert. Die Backend-Hotspots (H2, H4) und die Hook-Kopplung (H5) sind unberührt. + +--- + +## 1. Aktualisiertes Architektur-Diagramm + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND (~13.074 LOC) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ arduino-simulator.tsx (2.266 LOC) — NOCH GOD COMPONENT │ │ +│ │ ~18 Custom Hooks • ~20 useState • 13 useEffect • 32 Props │ │ +│ │ │ │ +│ │ ┌──────────────┐ ←→ ┌───────────────────┐ │ │ +│ │ │use-compilation│ │use-simulation- │ ← NOCH │ │ +│ │ │ (472 LOC) │ │ controls (257) │ BIDIREKTIONAL │ │ +│ │ │ 20 Params │ │ 16 Params │ │ │ +│ │ └──────┬───────┘ └──────┬────────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌──────────────┐ ┌───────────────────┐ ← NEU EXTRAHIERT │ │ +│ │ │useSketch │ │useWebSocket │ │ │ +│ │ │ Analysis │ │ Handler (374) │ │ │ +│ │ │ (157 LOC) │ └──────┬────────────┘ │ │ +│ │ └──────────────┘ │ │ │ +│ │ ┌──────────────┐ ┌──────┴────────────┐ │ │ +│ │ │useSerialIO │ │websocket-manager │ │ │ +│ │ │ (123 LOC) │ │ (456 LOC) │ │ │ +│ │ └──────────────┘ └──────┬────────────┘ │ │ +│ │ ┌──────────────┐ │ │ │ +│ │ │useFileManager│ │ sendImmediate() ← STOP-FIX │ │ +│ │ │ (73 LOC) │ │ │ │ +│ │ └──────────────┘ │ │ │ +│ └───────────────────────────────┼──────────────────────────────────-─┘ │ +│ SimCockpit (37) │ 17 UI-Components │ +│ + 11 Feature-Comp. │ (shadcn/ui) │ +└──────────────────────────────────┼────────────────────────────────────────-┘ + │ ws:// +┌──────────────────────────────────┼────────────────────────────────────────-┐ +│ BACKEND (~6.100 LOC) │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ routes.ts (202) + 3 Route-Module (385) ← MODULARISIERT │ │ +│ │ simulation.ws.ts: 7 Cases, start_simulation: ~124 LOC │ │ +│ └──────────────────────────────────┬──────────────────────────────────-┘ │ +│ │ │ +│ ┌──────────────────────────────────▼──────────────────────────────────┐ │ +│ │ sandbox-runner.ts (1.427 LOC) — NOCH GOD OBJECT │ │ +│ │ 28 private Felder • 6 Verantwortlichkeiten • 11-Param runSketch() │ │ +│ └──────────┬──────────┬──────────┬──────────┬────────────────────────-┘ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐ │ +│ │registry-mgr │ │pin-state │ │serial- │ │arduino-output│ │ +│ │ │ │batcher │ │batcher │ │parser │ │ +│ └─────────────┘ └──────────┘ └─────────┘ └──────────────┘ │ +│ │ +│ shared/code-parser.ts (622 LOC) — UNVERÄNDERT │ +│ server/mocks/arduino-mock.ts (941 LOC) — UNVERÄNDERT │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Die 5 Hotspots — Aktualisierter Status + +### Hotspot #1: `arduino-simulator.tsx` — von 2.761 auf 2.266 LOC (−18%) + +**Status: 🟡 TEILWEISE ADRESSIERT — Fortführung nötig** + +Was bereits herausgelöst wurde: +- ✅ `useWebSocketHandler` (374 LOC) — WS-Message-Dispatch +- ✅ `useSketchAnalysis` (157 LOC) — Analog-Pin-Detection +- ✅ `useSerialIO` (123 LOC) — Serial-State-Management +- ✅ `useFileManager` (73 LOC) — File I/O +- ✅ `SimCockpit` (37 LOC) — Telemetry-Indicator + +Was noch übrig ist (verbleibende ~2.266 LOC): +- 🔴 Output-Panel Inline-Rendering (~400 LOC JSX, L1395–L1809) +- 🔴 Mobile-Layout als `createPortal`-Block (~170 LOC, L2095+) +- 🟠 13 `useEffect`-Blöcke im Scope +- 🟠 ~20 `useState`-Deklarationen +- 🟠 32 Props an `AppHeader` + +### Hotspot #2: `sandbox-runner.ts` — 1.427 LOC (−3,5%) + +**Status: 🔴 NICHT ADRESSIERT** + +Die marginale Reduktion (52 LOC) kommt aus kleinen Bereinigungen, nicht aus strukturellem Refactoring. Alle 6 Verantwortlichkeiten sind noch in einer Klasse. + +### Hotspot #3: `routes.ts` — 744 → 587 LOC verteilt auf 4 Dateien + +**Status: 🟢 P0 ABGESCHLOSSEN** + +Die Modularisierung in `auth.routes.ts`, `compiler.routes.ts` und `simulation.ws.ts` ist sauber umgesetzt. Die größte Einzelfunktion (`start_simulation`) ist mit ~124 LOC noch groß, aber in einem dedizierten Modul isoliert. + +### Hotspot #4: `code-parser.ts` — 622 LOC (unverändert) + +**Status: 🟠 NICHT ADRESSIERT** — Niedrigere Priorität, da keine funktionale Instabilität. + +### Hotspot #5: Hook-Kopplung — Leicht verschärft + +**Status: 🔴 VERSCHÄRFT** + +`use-simulation-controls.ts` ist um 17 LOC gewachsen (16 → 16 Parameter + `sendMessageImmediate`). Die bidirektionale Kopplung über `startSimulationRef` / `setHasCompiledOnceRef` besteht weiterhin. + +--- + +## 3. Resiliente Refactoring-Roadmap v2 + +### Leitprinzipien (Lessons Learned) + +> **Prinzip 1: Chirurgische Extraktion — keine Architektur-Umbauten** +> Jede Änderung ist eine reine _Move_-Operation: Code wird ausgeschnitten, in eine neue Datei eingefügt, und an der Originalstelle durch einen Import + Aufruf ersetzt. Die Aufruf-Signatur bleibt identisch. + +> **Prinzip 2: WS-Pfad ist Tabuzone** +> Die Kette `sendMessage()` → `websocket-manager.send()` → `Server ws.on("message")` → `simulation.ws.ts switch` → `runner.runSketch()` → `Callback` → `ws.send()` → `useWebSocketHandler.processMessage()` → `setState` darf bei keiner Extraktion unterbrochen werden. Kein Callback darf eine neue Signatur bekommen. + +> **Prinzip 3: Tests sind unveränderlich bis grün** +> Die in Abschnitt 4 definierten Guardian-Tests dürfen nur geändert werden, wenn ein neuer Test den alten _strikt erweitert_ (= alter Test ist Subset des neuen). Nie löschen, nie lockern. + +> **Prinzip 4: Ein PR = eine Extraktion** +> Jeder Schritt ist atomar committable und testbar. Kein "ich mache 3 Extraktionen gleichzeitig". + +--- + +### Phase A: Frontend — Sichere Extraktionen aus `arduino-simulator.tsx` + +Jede Extraktion folgt diesem Muster: + +``` +1. Bestehendes Verhalten durch Guardian-Tests abgedeckt? → Ja: weiter. Nein: ERST Test schreiben. +2. Code in neue Datei verschieben (copy-paste, kein Rewrite) +3. An Originalstelle: import + Aufruf mit IDENTISCHEN Parametern +4. `npm run test` + `npm run test:e2e` müssen grün sein +5. Commit +``` + +#### A1: `` Komponente extrahieren +**Impact: −400 LOC | Risiko: NIEDRIG | Priorität: SOFORT** + +| Was | Detail | +|-----|--------| +| **Scope** | L1395–L1809: Das `` mit 4 Tabs (Compiler, Messages, I/O Registry, Debug) | +| **Neue Datei** | `client/src/components/features/output-panel.tsx` | +| **Props-Interface** | `{ activeTab, onTabChange, cliOutput, parserMessages, ioRegistry, debugMode, debugMessages, showCompilationOutput, onClose, compilationStatus, onClearCompilationOutput, onClearDebugMessages, onCopyDebugMessages }` | +| **Memoization** | `React.memo(OutputPanel)` mit shallow-compare auf Props. `cliOutput` und `parserMessages` sind Arrays — Referenzstabilität ist gegeben, da sie über `useState`-Setter aktualisiert werden (neue Referenz nur bei echten Änderungen) | +| **Flicker-Prevention** | Keine — reine JSX-Verschiebung, kein State-Refactoring. Die Tabs rendern nur wenn `showCompilationOutput === true` | +| **Test** | Bestehende `output-panel-*.test.tsx` Tests müssen weiter grün sein. E2E: `output-panel-floor.spec.ts` prüft min-height | + +#### A2: `` Komponente extrahieren +**Impact: −170 LOC | Risiko: NIEDRIG | Priorität: SOFORT** + +| Was | Detail | +|-----|--------| +| **Scope** | L2095+: Der `createPortal`-Block mit FAB-Buttons und Overlay-Panels | +| **Neue Datei** | `client/src/components/features/mobile-layout.tsx` | +| **Props-Interface** | `{ mobilePanel, onPanelChange, headerHeight, overlayZ, children: { code, serial, board } }` — wobei `children` als Render-Props oder named Slots übergeben werden | +| **Memoization** | `React.memo` mit Custom Comparator: nur `mobilePanel` und `headerHeight` triggern Re-Render | +| **Flicker-Prevention** | `overlayZ` ist ein stabiler Wert aus `useMobileLayout`. Panels werden via `display: none/block` ein-/ausgeblendet, nicht via mount/unmount — dadurch kein Layout-Shift | + +#### A3: `useEditorCommands` Hook extrahieren +**Impact: −109 LOC | Risiko: SEHR NIEDRIG | Priorität: KURZ** + +| Was | Detail | +|-----|--------| +| **Scope** | Die 6 Wrapper-Funktionen für Monaco-Editor-Commands (undo, redo, cut, copy, paste, selectAll, goToLine, find) | +| **Neue Datei** | `client/src/hooks/use-editor-commands.ts` | +| **Signatur** | `useEditorCommands(editorRef: RefObject) → { onUndo, onRedo, onCut, onCopy, onPaste, onSelectAll, onGoToLine, onFind }` | +| **Memoization** | Jede Funktion ist bereits `useCallback` oder trivial genug, um keine Memoization zu benötigen (sie rufen nur `editorRef.current?.executeCommand()` auf) | + +#### A4: AppHeader Props reduzieren +**Impact: LOC-neutral, aber −22 Props | Risiko: MITTEL | Priorität: MITTEL** + +| Was | Detail | +|-----|--------| +| **Strategie** | KEIN Context-Provider (zu invasiv). Stattdessen: Props gruppieren in 3 Sub-Objekte | +| **Vorher** | 32 einzelne Props | +| **Nachher** | `simulationProps: { status, onSimulate, onStop, ... }`, `editorProps: { onUndo, onRedo, ... }`, `fileProps: { onFileAdd, onLoadFiles, ... }` + ~5 verbleibende Top-Level-Props | +| **Memoization** | Jedes Sub-Objekt wird mit `useMemo(() => ({ ... }), [deps])` stabilisiert. Damit re-rendert `AppHeader` NUR wenn sich relevante Werte ändern | +| **Flicker-Prevention** | `React.memo(AppHeader)` mit den 3 stabilen Objekt-Referenzen statt 32 individuellen Props = drastisch weniger Re-Renders | + +#### A5: Hook-Merger `useCompileAndRun` (ehem. H5) +**Impact: −60 LOC + Kopplung eliminiert | Risiko: HOCH | Priorität: NACH A1–A4** + +> ⚠️ **ACHTUNG:** Dies ist die riskanteste Frontend-Extraktion. Sie darf ERST nach A1–A4 erfolgen, wenn die Guardian-Tests stabil laufen und die Datei kleiner ist. + +| Was | Detail | +|-----|--------| +| **Strategie** | `use-compilation.ts` (472 LOC) und `use-simulation-controls.ts` (257 LOC) werden zu **einem** Hook `useCompileAndRun` zusammengeführt | +| **Warum Merge statt Entkopplung** | Die beiden Hooks SIND ein Workflow. Die bidirektionale Ref-Bridge (`startSimulationRef`, `setHasCompiledOnceRef`) beweist, dass sie zusammengehören. Getrennte Hooks + Ref-Bridge = künstliche Modulgrenze mit impliziter Kopplung. Ein Hook = explizite Kopplung | +| **Signatur** | `useCompileAndRun(options: CompileAndRunOptions) → CompileAndRunAPI` | +| **Options** | `{ editorRef, tabs, activeTabId, code, sendMessage, sendMessageImmediate, ensureBackendConnected, toast }` — 8 statt 33 Parameter | +| **Interne Struktur** | `compilationState` + `simulationState` + `mutations` als geschlossene Einheit. `startSimulation()` ruft intern `compileMutation` auf, kein Ref-Bridge nötig | +| **WS-Pfad-Sicherheit** | `sendMessage` und `sendMessageImmediate` werden 1:1 durchgereicht — der WS-Buffer-Pfad wird NICHT verändert | +| **Flicker-Prevention** | `useReducer` statt 12 `useState`-Calls für zusammenhängende State-Transitions (`compileStart → compileSuccess → simulationStart`). Ein Dispatch = ein Re-Render statt 3 sequentieller `setState`-Calls | +| **Test-Strategie** | Bestehende Unit-Tests für beide Hooks werden in eine neue Test-Datei `use-compile-and-run.test.ts` migriert. Die alten Test-Dateien bleiben als Regression-Check bestehen, bis der Merge stabil ist | + +--- + +### Phase B: Backend — Chirurgische Extraktionen aus `sandbox-runner.ts` + +> **Kritische Erkenntnis aus dem Post-Mortem:** Der `SandboxRunner` ist das Herz der Server-Client-Kommunikation. Jeder Callback, der an `runSketch()` übergeben wird, ist ein Endpunkt einer WS-Message-Kette. Wir dürfen die Callback-Signaturen NICHT ändern. + +#### B1: `RunSketchOptions` Interface einführen +**Impact: LOC-neutral | Risiko: SEHR NIEDRIG | Priorität: SOFORT** + +```typescript +// server/services/types.ts (NEU) +export interface RunSketchOptions { + code: string; + timeoutSec?: number; + onOutput: (line: string, isComplete?: boolean) => void; + onError: (line: string) => void; + onExit: (code: number | null) => void; + onCompileError?: (error: string) => void; + onCompileSuccess?: () => void; + onPinState?: (pin: number, type: "mode" | "value" | "pwm", value: number) => void; + onIORegistry?: (registry: IOPinRecord[], baudrate: number | undefined, reason?: string) => void; + onTelemetry?: (metrics: any) => void; + onPinStateBatch?: (batch: PinStateBatch) => void; +} +``` + +| Schritt | Detail | +|---------|--------| +| 1 | Interface in `server/services/types.ts` definieren | +| 2 | `runSketch(options: RunSketchOptions)` statt 11 Positional-Parameter | +| 3 | In `simulation.ws.ts`: Caller-Site von Positional auf Object-Spread umstellen | +| 4 | Alle Tests: Search-Replace von Positional-Calls auf Options-Objekt | +| 5 | `npm run test` muss grün sein — kein Verhaltens-Unterschied | + +#### B2: `setupDockerHandlers` + `setupLocalHandlers` unifizieren +**Impact: −100 LOC | Risiko: MITTEL | Priorität: NACH B1** + +| Was | Detail | +|-----|--------| +| **Ist-Zustand** | Beide Methoden sind ~70% identisch. Unterschied: Docker hat `compilePhase`-Flag, Local hat `wasRunning`-Guard | +| **Soll** | Eine `setupProcessHandlers(process, options: { hasCompilePhase: boolean })` Methode | +| **Pattern** | Strategy via Options-Flag statt Vererbung — minimal-invasiv | +| **WS-Pfad-Sicherheit** | `handleParsedLine()` (L912) bleibt unverändert — es ist der einzige Punkt, an dem Callbacks aufgerufen werden. Die Handler-Setup-Methode verbindet nur `stdout`/`stderr`/`close`-Events | +| **Test** | `sandbox-runner.test.ts` muss alle bestehenden Szenarien (Docker + Local) weiter bestehen | + +#### B3: `ProcessManager` extrahieren +**Impact: −150 LOC | Risiko: MITTEL | Priorität: NACH B2** + +| Was | Detail | +|-----|--------| +| **Scope** | `spawn()`, `pause()`, `resume()`, `stop()`, `kill()`, `processKilled`-Flag, `processController`-Management | +| **Neue Datei** | `server/services/process-manager.ts` | +| **Interface** | `ProcessManager { spawn(cmd, args, opts); pause(); resume(); stop(); kill(); isKilled: boolean; onClose(cb); onStdout(cb); onStderr(cb); }` | +| **WS-Pfad-Sicherheit** | `ProcessManager` hat KEINE Kenntnis von WebSockets oder Callbacks. Es ist eine reine OS-Prozess-Abstraktion. `SandboxRunner` verbindet `ProcessManager.onStderr` → `handleParsedLine` → Callbacks | +| **Kritischer Punkt** | `stop()` muss weiterhin `serialOutputBatcher.destroy()` aufrufen (nicht `.stop()`!) — der Stop-Fix darf nicht verloren gehen. Lösung: `SandboxRunner.stop()` ruft `processManager.kill()` UND `serialOutputBatcher.destroy()` auf. Der Batcher bleibt in `SandboxRunner` | + +#### B4: `CleanupManager` extrahieren +**Impact: −80 LOC | Risiko: NIEDRIG | Priorität: NACH B3** + +| Was | Detail | +|-----|--------| +| **Scope** | Temp-Dir-Erstellung, Temp-Dir-Cleanup mit Retry-Logik, Registry-File-Cleanup | +| **Neue Datei** | `server/services/cleanup-manager.ts` | +| **Besonderheit** | Reine I/O-Operationen ohne Callback-Verbindung zum Frontend. Sicherste Extraktion im Backend | + +--- + +### Phase C: Shared — `code-parser.ts` Plugin-Pattern + +**Priorität: NIEDRIG — nur wenn Phase A+B abgeschlossen** + +#### C1: Checker-Pattern einführen + +```typescript +// shared/checkers/types.ts +export interface CodeChecker { + name: string; + check(code: string, cleanCode: string): ParserMessage[]; +} + +// shared/checkers/serial-checker.ts (113 LOC) +// shared/checkers/structure-checker.ts (58 LOC) +// shared/checkers/hardware-checker.ts (173 LOC) +// shared/checkers/pin-conflict-checker.ts (44 LOC) +// shared/checkers/performance-checker.ts (103 LOC) + +// shared/code-parser.ts (Facade, ~30 LOC) +export class CodeParser { + private checkers: CodeChecker[] = [ + new SerialChecker(), + new StructureChecker(), + new HardwareChecker(), + new PinConflictChecker(), + new PerformanceChecker(), + ]; + + parseAll(code: string): ParserMessage[] { + const cleanCode = this.removeComments(code); + return this.checkers.flatMap(c => c.check(code, cleanCode)); + } +} +``` + +| Vorteil | Detail | +|---------|--------| +| API-stabil | `CodeParser.parseAll()` bleibt unverändert — kein Caller muss geändert werden | +| Testbar | Jeder Checker hat eigene Test-Datei statt monolithischer Parser-Tests | +| Erweiterbar | Neuer Checker = neue Datei + Array-Eintrag, keine Änderung an bestehenden Checkern | + +--- + +### Phase D: Test-Konsolidierung + +#### D1: Load-Tests parametrisieren +**Impact: −1.300 LOC | Risiko: SEHR NIEDRIG | Priorität: JEDERZEIT (unabhängig)** + +```typescript +// tests/server/load-test.test.ts (EINZIGE Datei) +import { describe, it } from 'vitest'; + +const LOAD_CONFIGS = [ + { clients: 50, passRate: 0.6, avgLimit: 40_000, timeout: 90_000 }, + { clients: 100, passRate: 0.55, avgLimit: 50_000, timeout: 180_000 }, + { clients: 200, passRate: 0.30, avgLimit: 60_000, timeout: 300_000 }, + { clients: 500, passRate: 0.25, avgLimit: 90_000, timeout: 720_000 }, +] as const; + +describe.each(LOAD_CONFIGS)( + 'Load Test — $clients clients', + ({ clients, passRate, avgLimit, timeout }) => { + it(`handles ${clients} concurrent clients`, async () => { + // ... identischer Test-Body mit parametrisierten Thresholds + }, timeout); + } +); +``` + +Die 4 alten Dateien werden gelöscht, nachdem die neue Datei alle Szenarien abdeckt. + +--- + +## 4. Guardian-Tests: Unantastbare Wächter + +### Definition + +**Guardian-Tests** sind Tests, die bei jedem Refactoring-Schritt grün sein MÜSSEN. Eine KI, die einen Guardian-Test ändert (lockert, löscht, oder `skip`t), bricht damit das Refactoring-Protokoll. + +### E2E Guardians (9 Spec-Dateien, 23 Test-Cases) + +| Guardian | Datei | Schützt | +|----------|-------|---------| +| **G1: WS-Flow** | `websocket-flow.spec.ts` | Die gesamte Compile→Start→Serial→PinState-Kette | +| **G2: Pin-Frames** | `arduino-board-pin-frames.spec.ts` (8 Tests) | Korrekte SVG-Darstellung aller Pin-Modi | +| **G3: Value-Display** | `arduino-board-value-display.spec.ts` | I/O-Value-Toggle funktioniert | +| **G4: Output-Floor** | `output-panel-floor.spec.ts` (2 Tests) | Output-Panel min-height bei Resize | +| **G5: Batching** | `pin-state-batching-telemetry.spec.ts` (4 Tests) | Pin-Batching End-to-End | +| **G6: Visual** | `visual-baseline.spec.ts` (2 Tests) | UI hat sich visuell nicht verändert | +| **G7: Sandbox** | `sandbox-ui-batching.spec.ts` | Sandbox→UI Integration | +| **G8: PWM** | `pwm-controller.spec.ts` | PWM-Controller Validierung | +| **G9: Keyboard** | `phase7r-keyboard-dropping.spec.ts` (3 Tests) | Shortcuts + Serial-Dropping | + +### Unit-Test Guardians (kritischste) + +| Guardian | Datei | Schützt | +|----------|-------|---------| +| **G10** | `tests/server/services/sandbox-runner.test.ts` | Runner-Lifecycle: start→output→stop→cleanup | +| **G11** | `tests/server/websocket-multi-client.test.ts` | Multi-Client WS-Isolation | +| **G12** | `tests/server/services/serial-output-batcher.test.ts` | Batcher flush vs destroy Semantik (Stop-Fix!) | +| **G13** | `tests/server/services/pin-state-batcher.test.ts` | Pin-Batching-Logik | + +### Protokoll für KI-gesteuerte Refactorings + +``` +VOR jedem Commit: + 1. npm run test → ALLE Unit-Tests grün + 2. npm run test:e2e → ALLE E2E-Tests grün + 3. git diff -- e2e/ → KEINE Änderungen an E2E-Specs + 4. git diff -- tests/server/services/sandbox-runner.test.ts → KEINE Änderungen + 5. git diff -- tests/server/services/serial-output-batcher.test.ts → KEINE Änderungen + +Falls eine dieser Prüfungen fehlschlägt: + → STOPP. Änderungen revertieren. Problem analysieren. + → NIE einen Test anpassen, um einen Fehler zu "fixen" +``` + +--- + +## 5. Anti-Flicker-Spezifikation + +### Problem + +Bei State-Extraktionen kann es zu "UI-Flackern" kommen, wenn: +1. Ein `useState` in eine neue Komponente verschoben wird und der Parent dadurch unnötig re-rendert +2. Mehrere `setState`-Calls in einem Event-Handler sequentiell feuern +3. Object-Props (z.B. `style={{ ... }}`) bei jedem Render neue Referenzen erzeugen + +### Verbindliche Regeln für jede Extraktion + +#### Regel F1: `React.memo` mit stabilen Props + +Jede extrahierte Komponente MUSS `React.memo` verwenden: + +```tsx +// ✅ KORREKT +export const OutputPanel = React.memo(function OutputPanel(props: OutputPanelProps) { + // ... +}); + +// ❌ VERBOTEN — Props ohne Memo +export function OutputPanel(props: OutputPanelProps) { ... } +``` + +#### Regel F2: Callback-Stabilität via `useCallback` + +Jede Callback-Prop, die an eine `React.memo`-Komponente durchgereicht wird, MUSS referenzstabil sein: + +```tsx +// ✅ KORREKT — in arduino-simulator.tsx +const handleTabChange = useCallback((tab: string) => { + setActiveOutputTab(tab); +}, []); // kein dep, da setActiveOutputTab stabil ist + + + +// ❌ VERBOTEN — Inline-Lambda erzeugt neue Referenz bei jedem Render + setActiveOutputTab(tab)} /> +``` + +#### Regel F3: Object-Props via `useMemo` + +Wenn Props als Objekt gruppiert werden (z.B. für AppHeader), MUSS `useMemo` verwendet werden: + +```tsx +// ✅ KORREKT +const simulationProps = useMemo(() => ({ + status: simulationStatus, + onSimulate: handleStart, + onStop: handleStop, + onPause: handlePause, + onResume: handleResume, +}), [simulationStatus, handleStart, handleStop, handlePause, handleResume]); + + +``` + +#### Regel F4: Kein Double-Render bei State-Batching + +React 18 batchet `setState`-Calls in Event-Handlern automatisch. Aber in `useEffect` und async Callbacks muss explizit gebatched werden: + +```tsx +// ✅ KORREKT — React batcht automatisch in Event-Handlern +const handleCompileSuccess = useCallback(() => { + setCompilationStatus('success'); + setHasCompilationErrors(false); + setCliOutput(prev => [...prev, 'Compilation successful']); +}, []); + +// ⚠️ VORSICHT — In async/useEffect: React 18 batcht auch hier, +// aber bei Extraktionen in Custom Hooks sicherstellen, dass +// zusammengehörige State-Updates im selben Synchron-Tick passieren +``` + +#### Regel F5: Keine Layout-Shifts bei Panel-Extraktion + +Das Output-Panel verwendet `ResizablePanel` mit `minSize`/`maxSize`. Bei der Extraktion: + +```tsx +// ✅ KORREKT — Panel-Constraints bleiben identisch + + + + +// ❌ VERBOTEN — Panel-Constraints IN die Komponente verschieben +// (das ResizablePanel MUSS im Parent bleiben, da es Teil des Layout-Grids ist) +``` + +--- + +## 6. Priorisierte Roadmap v2 — Zeitplan + +``` +SOFORT (Sprint 1) KURZ (Sprint 2-3) MITTEL (Sprint 4+) +───────────────── ────────────────── ────────────────── + +Frontend: Frontend: Frontend: +[A1] OutputPanel extrahieren [A3] useEditorCommands [A5] useCompileAndRun +[A2] MobileLayout extrahieren [A4] AppHeader Props Merger + +Backend: Backend: Backend: +[B1] RunSketchOptions Interface [B2] Handler-Unifikation [B3] ProcessManager + [B4] CleanupManager + +Tests: Shared: Shared: +[D1] Load-Tests parametrisieren — [C1] Parser Checker-Pattern + +Geschätzte LOC-Reduktion: Geschätzte LOC-Reduktion: Geschätzte LOC-Reduktion: +Source: −570 (Output+Mobile) Source: −169 (EditorCmd+Props) Source: −390 (Hooks+Process+ +Tests: −1.300 (Load-Tests) Tests: 0 Cleanup+Parser) + Tests: −200 (shared utils) +``` + +### Impact-Matrix v2 + +| Schritt | Kognitive Last Δ | Risiko | WS-Pfad betroffen? | Guardian-Tests | +|---------|-------------------|--------|---------------------|----------------| +| A1: OutputPanel | −400 LOC in God Component | 🟢 Niedrig | Nein | G4, G6 | +| A2: MobileLayout | −170 LOC in God Component | 🟢 Niedrig | Nein | G6 | +| A3: EditorCommands | −109 LOC in God Component | 🟢 Sehr niedrig | Nein | G9 | +| A4: AppHeader Props | −22 Props, LOC-neutral | 🟡 Mittel | Nein | G6 | +| A5: Hook-Merger | Eliminiert Ref-Bridge | 🔴 Hoch | Ja (sendMessage-Pfad) | G1, G5, G7 | +| B1: RunSketchOptions | LOC-neutral, API-Cleanup | 🟢 Sehr niedrig | Nein (Signatur-intern) | G10 | +| B2: Handler-Unifikation | −100 LOC Duplikation | 🟡 Mittel | Ja (stderr-Parsing) | G1, G5, G10 | +| B3: ProcessManager | −150 LOC, SRP | 🟡 Mittel | Indirekt (kill-Semantik) | G1, G10, G12 | +| B4: CleanupManager | −80 LOC, SRP | 🟢 Niedrig | Nein | G10 | +| C1: Parser-Checker | LOC-neutral, SRP | 🟢 Niedrig | Nein | Unit-Tests | +| D1: Load-Tests | −1.300 LOC Tests | 🟢 Sehr niedrig | Nein | Keine | + +--- + +## 7. Spezifikation für KI-Ausführende ("Raptor-Proof") + +### Vertrag mit der ausführenden KI + +Jede ausführende KI erhält folgende Constraints: + +```markdown +## VERBOTEN: +1. Tests ändern, löschen oder mit `.skip` / `.todo` markieren +2. Callback-Signaturen in sandbox-runner.ts ändern +3. `sendMessageImmediate` entfernen oder durch `sendMessage` ersetzen +4. `serialOutputBatcher.destroy()` in stop() durch `.stop()` ersetzen +5. Mehr als EINE Extraktion pro Commit durchführen +6. useEffect-Dependencies ändern (außer bei nachweisbarem Bug) +7. WebSocket-Message-Typen umbenennen oder neue einführen +8. React.memo von bestehenden Komponenten entfernen + +## PFLICHT: +1. Jede neue Komponente mit React.memo wrappen +2. Jede neue Callback-Prop mit useCallback stabilisieren +3. Jede Object-Prop mit useMemo stabilisieren +4. Nach JEDEM Commit: `npm run test && npm run test:e2e` +5. `git diff -- e2e/` muss leer sein (keine E2E-Änderungen) +6. Imports alphabetisch sortiert halten +7. TypeScript strict mode Fehler beheben, nicht unterdrücken +``` + +### Commit-Message-Format + +``` +refactor(A1): extract OutputPanel component + +- Moved L1395-L1809 from arduino-simulator.tsx to output-panel.tsx +- Added React.memo wrapper +- Props: { activeTab, onTabChange, cliOutput, ... } — 13 props +- NO behavioral changes +- Tests: ✅ unit (187 passed), ✅ e2e (23 passed) +- Guardian check: git diff -- e2e/ → empty +``` + +--- + +## 8. Zusammenfassung v2 + +| Kennzahl | Audit v1 | Jetzt (eaf1220+Fixes) | Ziel (nach Roadmap v2) | +|----------|----------|------------------------|------------------------| +| Größte Datei (Source) | 2.761 LOC | 2.266 LOC | ~1.100 LOC | +| `routes.ts` | 744 LOC (1 Fn) | 587 LOC (4 Dateien) ✅ | — | +| `sandbox-runner.ts` | 1.479 LOC | 1.427 LOC | ~800 LOC | +| Max. Hooks/Komponente | 52 | ~38 | ~12 | +| Max. Props an AppHeader | ~35 | 32 | ~8 (3 Gruppen + 5) | +| Hook-Parameter (max) | 20+13=33 | 20+16=36 | ~8 (Options-Objekt) | +| Test-Duplikation (Load) | 1.731 LOC | 1.731 LOC | ~450 LOC | +| WS-Pfad Integrität | — | ✅ Stabil | ✅ Garantiert durch Guardians | + +**Was sich gegenüber v1 geändert hat:** + +1. **Roadmap ist defensiver:** Statt 3 "Phasen" (P0/P1/P2) gibt es jetzt atomare Schritte (A1–A5, B1–B4, C1, D1), jeweils mit explizitem Risk-Assessment +2. **Guardian-Tests sind definiert:** 13 Test-Suites als unveränderliche Invarianten +3. **Anti-Flicker-Regeln:** 5 verbindliche Regeln für Memoization und Referenzstabilität +4. **KI-Constraints:** Expliziter "Vertrag" mit Verboten und Pflichten +5. **WS-Pfad als Tabuzone:** Keine Extraktion darf den Message-Pfad verändern +6. **H3 ist erledigt:** `routes.ts` Modularisierung bereits umgesetzt — aus der Roadmap gestrichen diff --git a/client/src/pages/arduino-simulator.tsx b/client/src/pages/arduino-simulator.tsx index 9b63d101..b5ad6bc2 100644 --- a/client/src/pages/arduino-simulator.tsx +++ b/client/src/pages/arduino-simulator.tsx @@ -1681,7 +1681,7 @@ export default function ArduinoSimulator() { From bf8cc78c3c9f260bf6a857cbf81b5f494964949a Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 15:28:54 +0100 Subject: [PATCH 3/5] test(output-panel): ensure OutputPanel doesn't mutate DOM when parent updates with stable callbacks --- tests/client/output-panel.test.tsx | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/client/output-panel.test.tsx diff --git a/tests/client/output-panel.test.tsx b/tests/client/output-panel.test.tsx new file mode 100644 index 00000000..86b976a7 --- /dev/null +++ b/tests/client/output-panel.test.tsx @@ -0,0 +1,106 @@ +import React, { Profiler, useCallback, useMemo, useRef, useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { OutputPanel } from "@/components/features/output-panel"; + +describe("OutputPanel — callback reference stability", () => { + it("does not re-render when parent updates unrelated state while callbacks and data props are stable", () => { + function Wrapper() { + const [count, setCount] = useState(0); + + // Stable (memoized) data props + const parserMessages = useMemo(() => [], [] as any); + const ioRegistry = useMemo(() => [], [] as any); + const debugMessages = useMemo(() => [], [] as any); + + // Stable callbacks (useCallback ensures referential stability) + const onTabChange = useCallback(() => {}, []); + const openOutputPanel = useCallback(() => {}, []); + const onClose = useCallback(() => {}, []); + const onClearCompilationOutput = useCallback(() => {}, []); + const onParserMessagesClear = useCallback(() => {}, []); + const onParserGoToLine = useCallback(() => {}, []); + const onInsertSuggestion = useCallback(() => {}, []); + const onRegistryClear = useCallback(() => {}, []); + const setDebugMessageFilter = useCallback(() => {}, []); + const setDebugViewMode = useCallback(() => {}, []); + const onCopyDebugMessages = useCallback(() => {}, []); + const onClearDebugMessages = useCallback(() => {}, []); + + // Stable refs + const outputTabsHeaderRef = useRef(null); + const parserMessagesContainerRef = useRef(null); + const debugMessagesContainerRef = useRef(null); + + return ( + <> + +
+ +
+ + ); + } + + render(); + + const container = screen.getByTestId("output-root"); + + // Observe DOM mutations inside the OutputPanel root + const records: MutationRecord[] = []; + const observer = new MutationObserver((mutations) => records.push(...mutations)); + observer.observe(container, { attributes: true, childList: true, subtree: true, characterData: true }); + + // Clear any mutations produced by initial mount + records.splice(0, records.length); + + // Trigger unrelated parent state update + fireEvent.click(screen.getByText("Inc")); + + // Allow microtask queue to settle and then check that OutputPanel DOM did not change + // (no unnecessary DOM mutations == no visible flicker) + return new Promise((resolve) => { + requestAnimationFrame(() => { + observer.disconnect(); + expect(records.length).toBe(0); + resolve(); + }); + }); + }); +}); From 9c1a21d21a0a869dfaa2d6e61ffb14de8ed34d54 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 23:11:31 +0100 Subject: [PATCH 4/5] refactor(A2): extract mobile layout component and memoize slots --- .../src/components/features/mobile-layout.tsx | 161 ++++ client/src/pages/arduino-simulator.tsx | 876 ++++++------------ tests/client/mobile-layout.test.tsx | 90 ++ 3 files changed, 514 insertions(+), 613 deletions(-) create mode 100644 client/src/components/features/mobile-layout.tsx create mode 100644 tests/client/mobile-layout.test.tsx diff --git a/client/src/components/features/mobile-layout.tsx b/client/src/components/features/mobile-layout.tsx new file mode 100644 index 00000000..10a05c81 --- /dev/null +++ b/client/src/components/features/mobile-layout.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Button } from "@/components/ui/button"; +import { Cpu, Wrench, Terminal, Monitor } from "lucide-react"; +import clsx from "clsx"; + +export type MobilePanel = "code" | "compile" | "serial" | "board" | null; + +export interface MobileLayoutProps { + isMobile: boolean; + mobilePanel: MobilePanel; + setMobilePanel: React.Dispatch>; + headerHeight: number; + overlayZ: number; + + // slots + codeSlot?: React.ReactNode; + compileSlot?: React.ReactNode; + serialSlot?: React.ReactNode; + boardSlot?: React.ReactNode; + + portalContainer?: HTMLElement | null; + className?: string; + testId?: string; + onOpenPanel?: (panel: MobilePanel) => void; + onClosePanel?: () => void; +} + +export const MobileLayout = React.memo(function MobileLayout({ + isMobile, + mobilePanel, + setMobilePanel, + headerHeight, + overlayZ, + codeSlot, + compileSlot, + serialSlot, + boardSlot, + portalContainer = typeof document !== "undefined" ? document.body : null, + className, + testId = "mobile-layout", + onOpenPanel, + onClosePanel, +}: MobileLayoutProps) { + // helpers + const handleToggle = React.useCallback( + (panel: MobilePanel) => { + setMobilePanel((prev: MobilePanel) => (prev === panel ? null : panel)); + if (panel === mobilePanel) { + onClosePanel?.(); + } else { + onOpenPanel?.(panel); + } + }, + [mobilePanel, setMobilePanel, onOpenPanel, onClosePanel], + ); + + // render fab bar via portal + const fabBar = ( +
+
+
+
+ + + + +
+
+
+
+ ); + + return ( + <> + {isMobile && portalContainer && ReactDOM.createPortal(fabBar, portalContainer)} + {mobilePanel && ( +
+
+ {mobilePanel === "code" && codeSlot} + {mobilePanel === "compile" && compileSlot} + {mobilePanel === "serial" && serialSlot} + {mobilePanel === "board" && boardSlot} +
+
+ )} + + ); +}); + +MobileLayout.displayName = "MobileLayout"; diff --git a/client/src/pages/arduino-simulator.tsx b/client/src/pages/arduino-simulator.tsx index b5ad6bc2..6732c277 100644 --- a/client/src/pages/arduino-simulator.tsx +++ b/client/src/pages/arduino-simulator.tsx @@ -1,6 +1,6 @@ //arduino-simulator.tsx -import { +import React, { useState, useEffect, useRef, @@ -8,25 +8,20 @@ import { lazy, Suspense, } from "react"; -import { createPortal } from "react-dom"; + import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - Cpu, Terminal, - Wrench, Trash2, ChevronsDown, BarChart, Monitor, Columns, - X, - Table, - LayoutGrid, } from "lucide-react"; import { InputGroup } from "@/components/ui/input-group"; import { clsx } from "clsx"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; + import { CodeEditor } from "@/components/features/code-editor"; import { SerialMonitor } from "@/components/features/serial-monitor"; import { CompilationOutput } from "@/components/features/compilation-output"; @@ -37,6 +32,8 @@ import { ArduinoBoard } from "@/components/features/arduino-board"; import { PinMonitor } from "@/components/features/pin-monitor"; import { AppHeader } from "@/components/features/app-header"; import { SimCockpit } from "@/components/features/sim-cockpit"; +import { OutputPanel } from "@/components/features/output-panel"; +import { MobileLayout } from "@/components/features/mobile-layout"; import { useWebSocket } from "@/hooks/use-websocket"; import { useWebSocketHandler } from "@/hooks/useWebSocketHandler"; import { useCompilation } from "@/hooks/use-compilation"; @@ -60,7 +57,7 @@ import { ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + import type { Sketch, ParserMessage, @@ -226,6 +223,8 @@ export default function ArduinoSimulator() { // Mobile layout (responsive design and panel management) const { isMobile, mobilePanel, setMobilePanel, headerHeight, overlayZ } = useMobileLayout(); + + const queryClient = useQueryClient(); const { toast } = useToast(); const { setDebugMode } = useDebugMode(); @@ -1024,6 +1023,69 @@ export default function ArduinoSimulator() { ); }; + /* OutputPanel callbacks (stabilized with useCallback per Anti‑Flicker rules) */ + const handleOutputTabChange = useCallback((v: "compiler" | "messages" | "registry" | "debug") => { + setActiveOutputTab(v); + }, [setActiveOutputTab]); + + const handleOutputCloseOrMinimize = useCallback(() => { + const currentSize = outputPanelRef.current?.getSize?.() ?? 0; + const isMinimized = currentSize <= outputPanelMinPercent + 1; + + if (isMinimized) { + setShowCompilationOutput(false); + setParserPanelDismissed(true); + outputPanelManuallyResizedRef.current = false; + } else { + setCompilationPanelSize(3); + outputPanelManuallyResizedRef.current = false; + if (outputPanelRef.current?.resize) { + outputPanelRef.current.resize(outputPanelMinPercent); + } + } + }, [outputPanelMinPercent, setShowCompilationOutput, setParserPanelDismissed, setCompilationPanelSize]); + + const handleParserMessagesClear = useCallback(() => setParserPanelDismissed(true), [setParserPanelDismissed]); + const handleParserGoToLine = useCallback((line: number) => { + logger.debug(`Go to line: ${line}`); + }, []); + + const handleInsertSuggestion = useCallback((suggestion: string, line?: number) => { + if ( + editorRef.current && + typeof (editorRef.current as any).insertSuggestionSmartly === "function" + ) { + suppressAutoStopOnce(); + (editorRef.current as any).insertSuggestionSmartly(suggestion, line); + toast({ + title: "Suggestion inserted", + description: "Code added to the appropriate location", + }); + } else { + console.error("insertSuggestionSmartly method not available on editor"); + } + }, [suppressAutoStopOnce, toast]); + + const handleRegistryClear = useCallback(() => {}, []); + + const handleSetDebugMessageFilter = useCallback((v: string) => setDebugMessageFilter(v.toLowerCase()), [setDebugMessageFilter]); + const handleSetDebugViewMode = useCallback((m: "table" | "tiles") => setDebugViewMode(m), [setDebugViewMode]); + const handleCopyDebugMessages = useCallback(() => { + const messages = debugMessages + .filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter) + .map((m) => `[${m.timestamp.toLocaleTimeString()}] ${m.sender.toUpperCase()} (${m.type}): ${m.content}`) + .join('\n'); + if (messages) { + navigator.clipboard.writeText(messages); + toast({ + title: "Copied to clipboard", + description: `${debugMessages.filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter).length} messages`, + }); + } + }, [debugMessages, debugMessageFilter, toast]); + + const handleClearDebugMessages = useCallback(() => setDebugMessages([]), [setDebugMessages]); + // Toggle INPUT pin value (called when user clicks on an INPUT pin square) const handlePinToggle = (pin: number, newValue: number) => { if (simulationStatus === "stopped") { @@ -1194,6 +1256,150 @@ export default function ArduinoSimulator() { void stopDisabled; void buttonsClassName; + // mobile layout slots (memoized for performance) + const codeSlot = React.useMemo( + () => ( + <> + + } + /> +
+ +
+ + ), + [ + tabs, + activeTabId, + handleTabClick, + handleTabClose, + handleTabRename, + handleTabAdd, + handleFilesLoaded, + formatCode, + handleLoadExample, + backendReachable, + code, + handleCodeChange, + handleCompileAndStart, + editorRef, + ], + ); + + const compileSlot = React.useMemo( + () => ( + <> + {!parserPanelDismissed && parserMessages.length > 0 && ( +
+ setParserPanelDismissed(true)} + onGoToLine={(line) => { + logger.debug(`Go to line: ${line}`); + }} + /> +
+ )} +
+ +
+ + ), + [ + parserPanelDismissed, + parserMessages, + ioRegistry, + cliOutput, + handleClearCompilationOutput, + ], + ); + + const serialSlot = React.useMemo( + () => ( + <> +
+ +
+ + ), + [ + renderedSerialOutput, + isConnected, + simulationStatus, + handleSerialSend, + handleClearSerialOutput, + showSerialMonitor, + autoScrollEnabled, + ], + ); + + const boardSlot = React.useMemo( + () => ( +
+ {pinMonitorVisible && ( + + )} +
+ +
+
+ ), + [ + pinMonitorVisible, + pinStates, + batchStats, + simulationStatus, + txActivity, + rxActivity, + handleReset, + handlePinToggle, + analogPinsUsed, + handleAnalogChange, + ], + ); + return (
{ - const ops = record.usedAt || []; - const digitalReads = ops.filter((u) => - u.operation.includes("digitalRead"), - ); - const digitalWrites = ops.filter((u) => - u.operation.includes("digitalWrite"), - ); - const pinModes = ops - .filter((u) => u.operation.includes("pinMode")) - .map((u) => { - const match = u.operation.match(/pinMode:(\d+)/); - const mode = match ? parseInt(match[1]) : -1; - return mode === 0 - ? "INPUT" - : mode === 1 - ? "OUTPUT" - : mode === 2 - ? "INPUT_PULLUP" - : "UNKNOWN"; - }); - const uniqueModes = [...new Set(pinModes)]; - const hasMultipleModes = uniqueModes.length > 1; - const hasIOWithoutMode = - (digitalReads.length > 0 || digitalWrites.length > 0) && - pinModes.length === 0; - return hasIOWithoutMode || hasMultipleModes; - }); // Show output panel if: // - User has NOT explicitly closed it (showCompilationOutput) @@ -1417,381 +1595,44 @@ export default function ArduinoSimulator() { id="output-under-editor" className={shouldShowOutput ? "" : "hidden"} > - - setActiveOutputTab( - v as "compiler" | "messages" | "registry" | "debug", - ) - } - className="h-full flex flex-col" - > -
- - openOutputPanel("compiler")} - className={clsx( - "h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", - { - "text-gray-400": - lastCompilationResult === null, - "text-green-400": - isSuccessState && - lastCompilationResult !== null, - "text-red-400": hasCompilationErrors, - }, - )} - > - - Compiler - - - openOutputPanel("messages")} - className={clsx( - "h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", - { - "text-orange-400": - parserMessages.length > 0, - "text-gray-400": - parserMessages.length === 0, - }, - )} - > - 0, - "text-gray-400": - parserMessages.length === 0, - })} - > - Messages - - - openOutputPanel("registry")} - className={clsx( - "h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", - { - "text-blue-400": hasIOProblems, - "text-gray-400": !hasIOProblems, - }, - )} - > - - I/O Registry - - - {debugMode && ( - openOutputPanel("debug")} - className="h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center text-cyan-400 gap-1.5" - > - Debug - {debugMessages.length > 0 && ( - - {debugMessages.length > 99 ? "99" : debugMessages.length} - - )} - - )} - -
-
- -
-
- - - - - - - setParserPanelDismissed(true)} - onGoToLine={(line) => { - logger.debug(`Go to line: ${line}`); - }} - onInsertSuggestion={(suggestion, line) => { - if ( - editorRef.current && - typeof (editorRef.current as any) - .insertSuggestionSmartly === "function" - ) { - suppressAutoStopOnce(); - ( - editorRef.current as any - ).insertSuggestionSmartly(suggestion, line); - toast({ - title: "Suggestion inserted", - description: - "Code added to the appropriate location", - }); - } else { - console.error( - "insertSuggestionSmartly method not available on editor", - ); - } - }} - hideHeader={true} - /> - - - - {}} - onGoToLine={(line) => { - logger.debug(`Go to line: ${line}`); - }} - hideHeader={true} - defaultTab="registry" - /> - + openOutputPanel(tab as any)} + onClose={handleOutputCloseOrMinimize} + + onClearCompilationOutput={handleClearCompilationOutput} + onParserMessagesClear={handleParserMessagesClear} + onParserGoToLine={handleParserGoToLine} + onInsertSuggestion={handleInsertSuggestion} + onRegistryClear={handleRegistryClear} + + setDebugMessageFilter={handleSetDebugMessageFilter} + setDebugViewMode={handleSetDebugViewMode} + onCopyDebugMessages={handleCopyDebugMessages} + onClearDebugMessages={handleClearDebugMessages} + /> - - {/* Only render debug content when tab is active to avoid lag */} - {activeOutputTab === "debug" && ( -
- {/* Debug Console Header */} -
-
- Filter: - -
-
-
} - - - - - - - {/* Debug Messages Table View - limited to 100 visible entries */} - {debugViewMode === "table" && ( - -
- - - - - - - - - - - {debugMessages - .filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter) - .slice(-100) - .map((msg, idx) => ( - - - - - - - - ))} - {debugMessages.filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter).length === 0 && ( - - - - )} - -
TimeSenderProtocolTypeContent
- {msg.timestamp.toLocaleTimeString()} - - - {msg.sender.toUpperCase()} - - - - {msg.protocol?.toUpperCase() || "?"} - - - {msg.type} - - {msg.content} -
- {debugMessages.length === 0 ? "No messages yet" : "No messages match filter"} -
-
- )} - - {/* Debug Messages Tiles View - limited to 50 visible entries */} - {debugViewMode === "tiles" && ( - -
-
- {debugMessages - .filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter) - .slice(-50) - .map((msg) => ( -
- {/* Header Row */} -
-
- - {msg.sender.toUpperCase()} - - - {msg.type} - -
- - {msg.timestamp.toLocaleTimeString()} - -
- {/* Content with JSON formatting */} -
-                                              {(() => {
-                                                try {
-                                                  const parsed = JSON.parse(msg.content);
-                                                  return JSON.stringify(parsed, null, 2);
-                                                } catch {
-                                                  return msg.content;
-                                                }
-                                              })()}
-                                            
-
- ))} - {debugMessages.filter((m) => !debugMessageFilter || m.type.toLowerCase() === debugMessageFilter).length === 0 && ( -
- {debugMessages.length === 0 ? "No messages yet" : "No messages match filter"} -
- )} -
-
-
- )} - - )} - -
); @@ -2057,208 +1898,17 @@ export default function ArduinoSimulator() { ) : ( -
- {/* Render tab bar in a portal so it's fixed to the viewport regardless of ancestor transforms */} - {typeof window !== "undefined" && - createPortal( -
-
-
-
- - - - -
-
-
-
, - document.body, - )} - - {mobilePanel && ( -
-
- {mobilePanel === "code" && ( -
- - } - /> -
- -
-
- )} - {mobilePanel === "compile" && ( -
- {!parserPanelDismissed && parserMessages.length > 0 && ( -
- setParserPanelDismissed(true)} - onGoToLine={(line) => { - logger.debug(`Go to line: ${line}`); - }} - /> -
- )} -
- -
-
- )} - {mobilePanel === "serial" && ( -
-
- -
-
- )} - {mobilePanel === "board" && ( -
-
- {pinMonitorVisible && ( - - )} -
- -
-
-
- )} -
-
- )} -
+ )} diff --git a/tests/client/mobile-layout.test.tsx b/tests/client/mobile-layout.test.tsx new file mode 100644 index 00000000..cfd335b3 --- /dev/null +++ b/tests/client/mobile-layout.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { MobileLayout, MobilePanel } from "@/components/features/mobile-layout"; + +// simple placeholder components +const Code = () =>
CODE
; +const Compile = () =>
COMPILE
; +const Serial = () =>
SERIAL
; +const Board = () =>
BOARD
; + +describe("MobileLayout component", () => { + it("renders nothing when not mobile and no panel", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows correct slot and calls setMobilePanel when buttons clicked", () => { + const setMobile = vi.fn(); + const onOpen = vi.fn(); + const onClose = vi.fn(); + + const { rerender } = render( + } + compileSlot={} + serialSlot={} + boardSlot={} + onOpenPanel={onOpen} + onClosePanel={onClose} + />, + ); + + // no overlay initially + expect(screen.queryByTestId("slot-code")).toBeNull(); + + // Buttons exist via portal + const codeBtn = screen.getByLabelText("Code Editor"); + const compileBtn = screen.getByLabelText("Compilation Output"); + const serialBtn = screen.getByLabelText("Serial Output"); + const boardBtn = screen.getByLabelText("Arduino Board"); + + // click code button + fireEvent.click(codeBtn); + // setMobilePanel is invoked with a functional updater; verify behaviour + const updater = setMobile.mock.calls[0][0] as (prev: MobilePanel) => MobilePanel; + expect(updater(null)).toBe("code"); + expect(onOpen).toHaveBeenCalledWith("code"); + + // simulate parent updating prop + rerender( + } + compileSlot={} + serialSlot={} + boardSlot={} + onOpenPanel={onOpen} + onClosePanel={onClose} + />, + ); + + // now code slot visible + expect(screen.getByTestId("slot-code")).toBeInTheDocument(); + + // click again to close + fireEvent.click(codeBtn); + // second call updater should close panel + const updater2 = setMobile.mock.calls[1][0] as (prev: MobilePanel) => MobilePanel; + expect(updater2("code")).toBe(null); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file From 3b12b736ae9c37f4e00728bdc37e11fd62def7ab Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 23:30:26 +0100 Subject: [PATCH 5/5] chore: add output-panel component to mobile-layout extraction branch (was untracked) --- .../src/components/features/output-panel.tsx | 282 ++++++++++++++++++ tests/client/mobile-layout.test.tsx | 2 +- tests/client/output-panel.test.tsx | 2 +- 3 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 client/src/components/features/output-panel.tsx diff --git a/client/src/components/features/output-panel.tsx b/client/src/components/features/output-panel.tsx new file mode 100644 index 00000000..eb5461e5 --- /dev/null +++ b/client/src/components/features/output-panel.tsx @@ -0,0 +1,282 @@ +import React from "react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { CompilationOutput } from "@/components/features/compilation-output"; +import { ParserOutput } from "@/components/features/parser-output"; +import { X, LayoutGrid, Table } from "lucide-react"; +import clsx from "clsx"; +import type { ParserMessage, IOPinRecord } from "@shared/schema"; +import type { DebugMessage } from "@/hooks/use-debug-console"; + +export type OutputTab = "compiler" | "messages" | "registry" | "debug"; + +export interface OutputPanelProps { + /* State */ + activeOutputTab: OutputTab; + showCompilationOutput: boolean; + isSuccessState: boolean; + isModified: boolean; + compilationPanelSize: number; + outputPanelMinPercent: number; + debugMode: boolean; + debugViewMode: "table" | "tiles"; + debugMessageFilter: string; + + /* Data */ + cliOutput: string; + parserMessages: ParserMessage[]; + ioRegistry: IOPinRecord[]; + debugMessages: DebugMessage[]; + lastCompilationResult: string | null; + hasCompilationErrors: boolean; + + /* Refs */ + outputTabsHeaderRef: React.RefObject; + parserMessagesContainerRef: React.RefObject; + debugMessagesContainerRef: React.RefObject; + + /* Actions */ + onTabChange: (tab: OutputTab) => void; + openOutputPanel: (tab: OutputTab) => void; + onClose: () => void; + getOutputPanelSize?: () => number; + resizeOutputPanel?: (percent: number) => void; + + onClearCompilationOutput: () => void; + onParserMessagesClear: () => void; + onParserGoToLine: (line: number) => void; + onInsertSuggestion: (suggestion: string, line?: number) => void; + onRegistryClear?: () => void; + + setDebugMessageFilter: (s: string) => void; + setDebugViewMode: (m: "table" | "tiles") => void; + onCopyDebugMessages: () => void; + onClearDebugMessages: () => void; +} + +export const OutputPanel = React.memo(function OutputPanel(props: OutputPanelProps) { + const { + activeOutputTab, + isSuccessState, + isModified, + cliOutput, + parserMessages, + ioRegistry, + debugMode, + debugViewMode, + debugMessageFilter, + debugMessages, + lastCompilationResult, + hasCompilationErrors, + outputTabsHeaderRef, + parserMessagesContainerRef, + debugMessagesContainerRef, + onTabChange, + openOutputPanel, + onClose, + onClearCompilationOutput, + onParserMessagesClear, + onParserGoToLine, + onInsertSuggestion, + onRegistryClear = () => {}, + setDebugMessageFilter, + setDebugViewMode, + onCopyDebugMessages, + onClearDebugMessages, + } = props; + + return ( + onTabChange(v as OutputTab)} className="h-full flex flex-col"> +
+ + openOutputPanel("compiler")} className={clsx("h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", { + "text-gray-400": lastCompilationResult === null, + "text-green-400": isSuccessState && lastCompilationResult !== null, + "text-red-400": hasCompilationErrors, + })}> + + Compiler + + + + openOutputPanel("messages")} className={clsx("h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", { + "text-orange-400": parserMessages.length > 0, + "text-gray-400": parserMessages.length === 0, + })}> + 0, + "text-gray-400": parserMessages.length === 0, + })}> + Messages + + + + openOutputPanel("registry")} className={clsx("h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center", { + "text-blue-400": ioRegistry.some((r) => { + const ops = r.usedAt || []; + const digitalReads = ops.filter((u) => u.operation.includes("digitalRead")); + const digitalWrites = ops.filter((u) => u.operation.includes("digitalWrite")); + const pinModes = ops.filter((u) => u.operation.includes("pinMode")).map((u) => { + const match = u.operation.match(/pinMode:(\d+)/); + const mode = match ? parseInt(match[1]) : -1; + return mode === 0 ? "INPUT" : mode === 1 ? "OUTPUT" : mode === 2 ? "INPUT_PULLUP" : "UNKNOWN"; + }); + const uniqueModes = [...new Set(pinModes)]; + const hasMultipleModes = uniqueModes.length > 1; + const hasIOWithoutMode = (digitalReads.length > 0 || digitalWrites.length > 0) && pinModes.length === 0; + return hasIOWithoutMode || hasMultipleModes; + }), + "text-gray-400": !ioRegistry.some(() => false), + })}> + { + const ops = r.usedAt || []; + const digitalReads = ops.filter((u) => u.operation.includes("digitalRead")); + const digitalWrites = ops.filter((u) => u.operation.includes("digitalWrite")); + const pinModes = ops.filter((u) => u.operation.includes("pinMode")).map((u) => { + const match = u.operation.match(/pinMode:(\d+)/); + const mode = match ? parseInt(match[1]) : -1; + return mode === 0 ? "INPUT" : mode === 1 ? "OUTPUT" : mode === 2 ? "INPUT_PULLUP" : "UNKNOWN"; + }); + const uniqueModes = [...new Set(pinModes)]; + const hasMultipleModes = uniqueModes.length > 1; + const hasIOWithoutMode = (digitalReads.length > 0 || digitalWrites.length > 0) && pinModes.length === 0; + return hasIOWithoutMode || hasMultipleModes; + }), + "text-gray-400": !ioRegistry.some(() => false), + })}> + I/O Registry + + + + {debugMode && ( + openOutputPanel("debug")} className="h-[var(--ui-button-height)] px-2 text-ui-xs data-[state=active]:bg-background rounded-sm py-0 leading-none flex items-center text-cyan-400 gap-1.5"> + Debug + {debugMessages.length > 0 && ( + + {debugMessages.length > 99 ? "99" : debugMessages.length} + + )} + + )} + + +
+
+ +
+
+ + + + + + + } + onClear={onParserMessagesClear} + onGoToLine={onParserGoToLine} + onInsertSuggestion={onInsertSuggestion} + hideHeader={true} + /> + + + + + + + + {activeOutputTab === "debug" && ( +
+
+
+ Filter: + +
+
+
+ )} + + + ); +}); diff --git a/tests/client/mobile-layout.test.tsx b/tests/client/mobile-layout.test.tsx index cfd335b3..daebf2b1 100644 --- a/tests/client/mobile-layout.test.tsx +++ b/tests/client/mobile-layout.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { MobileLayout, MobilePanel } from "@/components/features/mobile-layout"; +import { MobileLayout, MobilePanel } from "../../client/src/components/features/mobile-layout"; // simple placeholder components const Code = () =>
CODE
; diff --git a/tests/client/output-panel.test.tsx b/tests/client/output-panel.test.tsx index 86b976a7..1b2d2248 100644 --- a/tests/client/output-panel.test.tsx +++ b/tests/client/output-panel.test.tsx @@ -1,7 +1,7 @@ import React, { Profiler, useCallback, useMemo, useRef, useState } from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { OutputPanel } from "@/components/features/output-panel"; +import { OutputPanel } from "../../client/src/components/features/output-panel"; describe("OutputPanel — callback reference stability", () => { it("does not re-render when parent updates unrelated state while callbacks and data props are stable", () => {