From 5e707224cffabb3320e87e8810f77fecb1b8305e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 30 Oct 2025 15:47:09 +0100 Subject: [PATCH] feat: Show connected robots finished with unit test 94% coverage ref: N25B-142 --- package-lock.json | 98 +++++++++++++++++ src/App.tsx | 22 +--- src/pages/ConnectedRobots/ConnectedRobots.tsx | 6 ++ .../connectedRobots/ConnectedRobots.test.tsx | 102 ++++++++++++++++++ 4 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 test/pages/connectedRobots/ConnectedRobots.test.tsx diff --git a/package-lock.json b/package-lock.json index c7346fa..40f413f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3323,6 +3323,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.9.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz", + "integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.72", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -7677,6 +7737,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -8159,6 +8228,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/src/App.tsx b/src/App.tsx index 819dae4..4fb4c42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { Routes, Route, Link } from 'react-router' -import { useState } from 'react' import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' @@ -9,16 +8,6 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx"; function App(){ - // Define what our conencted robot should include - type Robot = { - id: string; - name: string; - port: number; - }; - - // (Acces to) the array of connected robots - const [connectedRobots, setConnectedRobots] = useState([]); - return (
@@ -30,16 +19,7 @@ function App(){ } /> } /> } /> - - } - /> + } />
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index a6f7ce7..8b4b898 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } from 'react' export default function ConnectedRobots() { const [connected, setConnected] = useState(null); + + + useEffect(() => { // We're excepting a stream of data like that looks like this: `data = False` or `data = True` const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); @@ -33,6 +36,9 @@ export default function ConnectedRobots() {

Is robot currently connected?

Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")}

+

+ {connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""} +

); diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx new file mode 100644 index 0000000..ffea6e3 --- /dev/null +++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, act, cleanup, waitFor } from '@testing-library/react'; +import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots'; + +// Mock event source +const mockInstances: MockEventSource[] = []; +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: any) { + // Trigger whatever the component listens to + this.onmessage?.({ data } as MessageEvent); + } + + close() { + this.closed = true; + } +} + +// mock event source generation with fake function that returns our fake mock source +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// clean after tests +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('ConnectedRobots', () => { + test('renders initial state correctly', () => { + render(); + + // Check initial texts (before connection) + expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument(); + expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument(); + expect( + screen.getByText(/If checking continues, make sure CB is properly loaded/i) + ).toBeInTheDocument(); + }); + + test('updates to connected when message data is true', async () => { + render(); + const eventSource = mockInstances[0]; + expect(eventSource).toBeDefined(); + + // Check state after getting 'true' message + await act(async () => { + eventSource.sendMessage('true'); + }); + + await waitFor(() => { + expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument(); + }); + }); + + test('updates to not connected when message data is false', async () => { + render(); + const eventSource = mockInstances[0]; + + // Check statew after getting 'false' message + await act(async () => { + eventSource.sendMessage('false'); + }); + + await waitFor(() => { + expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument(); + }); + }); + + test('handles invalid JSON gracefully', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + const eventSource = mockInstances[0]; + + await act(async () => { + eventSource.sendMessage('not-json'); + }); + + expect(logSpy).toHaveBeenCalledWith( + 'Ping message not in correct format:', + 'not-json' + ); + }); + + test('closes EventSource on unmount', () => { + render(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + cleanup(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); \ No newline at end of file