From bf8cc78c3c9f260bf6a857cbf81b5f494964949a Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Fri, 20 Feb 2026 15:28:54 +0100 Subject: [PATCH 1/3] 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 2/3] 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: - -
-
-
- )} - - ); @@ -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 3/3] 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", () => {