diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 000000000..99e9a2df4 --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,85 @@ +name: E2E Tests + +on: + pull_request: + branches: + - main + - develop + - production + - "frontend/**" + - "control-station/**" + - "testing-view/**" + - "e2e/**" + paths: + - "frontend/testing-view/**" + - "electron-app/**" + - "e2e/**" + - "pnpm-lock.yaml" + - ".github/workflows/e2e-tests.yaml" + +jobs: + e2e: + name: Playwright E2E Tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Build backend binary + run: | + mkdir -p electron-app/binaries + cd backend/cmd && go build -o ../../electron-app/binaries/backend-darwin-arm64 . + + - name: Patch backend config + run: sed -i '' 's/validate = true/validate = false/' backend/cmd/dev-config.toml + + - name: Add loopback aliases required by backend + run: sudo ifconfig lo0 alias 127.0.0.9 + + - name: Debug - test backend binary + run: | + ./electron-app/binaries/backend-darwin-arm64 --config backend/cmd/dev-config.toml 2>&1 & + PID=$! + sleep 5 + kill $PID 2>/dev/null || true + continue-on-error: true + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Electron binary + run: node node_modules/electron/install.js + working-directory: e2e + + - name: Debug - verify Electron binary + run: | + node -e "const e = require('electron'); console.log('Electron path:', e);" + ls -la "$(node -e "process.stdout.write(require('electron'))")" + working-directory: e2e + + - name: Build testing-view (e2e mode) + run: | + pnpm --filter testing-view build:e2e + mkdir -p electron-app/renderer/testing-view + cp -r frontend/testing-view/dist/. electron-app/renderer/testing-view/ + + - name: Run UI tests + run: pnpm --filter e2e exec playwright test tests/ui/ + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 7 diff --git a/.github/workflows/electron-tests.yaml b/.github/workflows/electron-tests.yaml new file mode 100644 index 000000000..0e5c1f1f0 --- /dev/null +++ b/.github/workflows/electron-tests.yaml @@ -0,0 +1,34 @@ +name: Electron Tests + +on: + pull_request: + branches: + - main + - develop + - production + - "frontend/**" + - "control-station/**" + - "testing-view/**" + paths: + - "electron-app/**" + - ".github/workflows/electron-tests.yaml" + +jobs: + test: + name: Run Electron Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter=hyperloop-control-station + + - name: Run tests + run: pnpm test --filter=hyperloop-control-station diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml index 0599a5097..f96ebfe4d 100644 --- a/.github/workflows/frontend-tests.yaml +++ b/.github/workflows/frontend-tests.yaml @@ -14,19 +14,9 @@ on: - "pnpm-lock.yaml" - ".github/workflows/frontend-tests.yaml" - push: - branches: - - "frontend/**" - - "testing-view/**" - - "competition-view/**" - paths: - - "frontend/**" - - "pnpm-lock.yaml" - - ".github/workflows/frontend-tests.yaml" - jobs: test: - name: Run Frontend Tests + name: Run Frontend Unit Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a8b3f41dd..242046961 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -222,7 +222,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.determine-version.outputs.version }} - name: Hyperloop Control Station v${{ needs.determine-version.outputs.version }} + name: Control Station v${{ needs.determine-version.outputs.version }} draft: ${{ needs.determine-version.outputs.is_draft }} generate_release_notes: true files: dist/**/* diff --git a/README.md b/README.md index 6f2466a18..fca4c0051 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Our `pnpm-workspace.yaml` defines the following workspaces: | `backend` | Go | Data ingestion and pod communication server | | `packet-sender` | Rust | Utility for simulating vehicle packets | | `hyperloop-control-station` | JS | The main Control Station electron desktop application | +| `e2e` | TS | End-to-end tests for the whole app (Playwright) | | `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | | `@workspace/core` | TS | Shared business logic and types (frontend-kit) | | `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | @@ -57,9 +58,19 @@ All Turbo scripts support filtering to target specific workspaces: #### Lifecycle Scripts - `pnpm build` – Compiles every package in the monorepo (Go binaries, Rust crates, and Vite apps). -- `pnpm test` – Runs all test suites across the repo (Vitest, Go tests, and Cargo tests). +- `pnpm test` – Runs all test suites across the repo (Vitest, Go tests, Cargo tests, and Playwright e2e tests). - `pnpm lint` – Runs ESLint across all TypeScript packages. - `pnpm preview` – Previews the production Vite builds for the frontend applications. + +#### Electron App Scripts + +- `pnpm start` – Launches the Electron app directly (requires a prior build). +- `pnpm build:win` – Packages the Electron app for Windows. +- `pnpm build:linux` – Packages the Electron app for Linux. +- `pnpm build:mac` – Packages the Electron app for macOS. + +#### Utility Scripts + - `pnpm ui:add ` - To add shadcn/ui components > Note: don't forget to also include it in frontend-kit/ui/src/components/shadcn/index.ts to be able to access it from @workspace/ui diff --git a/backend/cmd/setup_vehicle.go b/backend/cmd/setup_vehicle.go index 8fe8999e2..19ce8765b 100644 --- a/backend/cmd/setup_vehicle.go +++ b/backend/cmd/setup_vehicle.go @@ -161,6 +161,7 @@ func configureHTTPServer( ) httpServer := h.NewServer(server.Addr, mux) + trace.Info().Str("localAddr", server.Addr).Msg("http server listening") go httpServer.ListenAndServe() } diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..945fcd0d8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..b3556080e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,57 @@ +# e2e + +End-to-end tests for the whole app. Tests run against the real Electron application using **Playwright** with the `@playwright/test` electron driver. + +--- + +## Overview + +Tests are split into two Playwright projects: + +| Project | Directory | Description | +| :------------ | :--------------- | :------------------------------------------------------------ | +| `ui` | `tests/ui/` | UI tests — launch the Electron app and interact with the UI | +| `integration` | `tests/integration/` | Integration tests (reserved for future use) | + +--- + +## UI Tests + +These tests launch the full Electron app and drive it through Playwright. They cover app startup, window titles, mode badge state, chart interactions, and filter dialog behaviour. + +--- + +## Fixtures + +`fixtures/electron.ts` provides three custom Playwright fixtures: + +| Fixture | Description | +| :--------- | :------------------------------------------------------------------ | +| `app` | Launches the Electron app and waits for both windows to be ready | +| `page` | The main Control Station window — waits for the app to leave loading state | +| `logPage` | The Backend Logs window | + +--- + +## Scripts + +| Script | Description | +| :--------------------- | :---------------------------------------------------------------- | +| `pnpm test` | Build all dependencies then run all tests | +| `pnpm test:ui` | Build all dependencies then run only `tests/ui` | +| `pnpm test:integration`| Build the electron app then run only `tests/integration` | +| `pnpm test:fast` | Run all tests without rebuilding (assumes already built) | +| `pnpm test:fast:ui` | Run `tests/ui` without rebuilding | +| `pnpm report` | Open the last Playwright HTML report | + +> **Note:** `pnpm test` and `pnpm test:ui` always build the `testing-view` (e2e mode) and the electron app before running. Use the `:fast` variants when iterating to skip the build step. + +--- + +## Requirements + +- The `hyperloop-control-station` electron app must be built (handled automatically by `pnpm test`). +- `testing-view` must be built in e2e mode (`build:e2e`), also handled automatically. +- Workers are set to `1` — Electron tests must run serially since only one app instance can run at a time. + +> **Note:** The app runs in its normal production mode during tests — there is no special test environment or mock mode. diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts new file mode 100644 index 000000000..22ea3d4ab --- /dev/null +++ b/e2e/fixtures/electron.ts @@ -0,0 +1,56 @@ +import { + _electron as electron, + test as base, + type ElectronApplication, + type Page, +} from "@playwright/test"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ELECTRON_APP_PATH = path.resolve(__dirname, "../../electron-app"); + +type ElectronFixtures = { + app: ElectronApplication; + page: Page; + logPage: Page; +}; + +export const test = base.extend({ + app: async ({}, use) => { + const app = await electron.launch({ + args: ["--no-sandbox", path.join(ELECTRON_APP_PATH, "main.js")], + cwd: ELECTRON_APP_PATH, + env: { + ...process.env, + NODE_ENV: "test", + }, + }); + + // Wait for both windows to open before yielding the app fixture, + // so logPage and logWindow fixtures can safely index into app.windows() + await app.firstWindow(); // Backend Logs — always first + await app.waitForEvent("window"); // Control Station — always second + + await use(app); + await app.close(); + }, + + // Backend logs window — always opens first + logPage: async ({ app }, use) => { + const page = app.windows()[0]; + await page.waitForLoadState("domcontentloaded"); + await use(page); + }, + + // Main control station window — always opens second + // Waits for the app to reach "active" mode before yielding + page: async ({ app }, use) => { + const page = app.windows()[1]; + await page.waitForLoadState("domcontentloaded"); + await page.waitForSelector('[data-testid="mode-badge"]:not([data-mode="loading"])', { timeout: 15000 }); + await use(page); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..0d7986fa6 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,19 @@ +{ + "name": "e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm --filter testing-view build:e2e && pnpm --filter hyperloop-control-station build:testing", + "test": "pnpm run build && playwright test", + "test:ui": "pnpm run build && playwright test tests/ui", + "test:integration": "pnpm --filter hyperloop-control-station build:testing && playwright test tests/integration", + "test:fast": "playwright test", + "test:fast:ui": "playwright test tests/ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "electron": "^40.1.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..881282100 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "@playwright/test"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + retries: 0, + workers: 1, // Electron tests must run serially — only one app instance at a time + + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report", open: "never" }], + ], + + projects: [ + { + name: "ui", + testDir: "./tests/ui", + use: {}, + }, + { + name: "integration", + testDir: "./tests/integration", + use: {}, + }, + ], +}); diff --git a/e2e/tests/ui/charts.test.ts b/e2e/tests/ui/charts.test.ts new file mode 100644 index 000000000..78c7aa9ed --- /dev/null +++ b/e2e/tests/ui/charts.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "../../fixtures/electron"; + +/** Wait for the Zustand persist middleware to rehydrate from localStorage. */ +async function waitForHydration(page: import("@playwright/test").Page) { + await page.waitForSelector('html[data-store-hydrated="true"]'); + return await page.getByTestId("chart").count(); +} + +test("add chart button is visible in toolbar", async ({ page }) => { + await expect(page.getByTestId("add-chart-button")).toBeVisible(); +}); + +test("add chart button adds a chart", async ({ page }) => { + const initialCount = await waitForHydration(page); + + await page.getByTestId("add-chart-button").click(); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 1); +}); + +test("clicking add chart multiple times adds multiple charts", async ({ + page, +}) => { + const initialCount = await waitForHydration(page); + + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 3); +}); + +test("charts are restored from localStorage after reload", async ({ page }) => { + const initialCount = await waitForHydration(page); + + // Add two charts on top of whatever is already persisted + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 2); + + // Reload — Zustand should restore all charts from localStorage + await page.reload(); + await waitForHydration(page); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 2); +}); diff --git a/e2e/tests/ui/filter-dialog.test.ts b/e2e/tests/ui/filter-dialog.test.ts new file mode 100644 index 000000000..b68b41327 --- /dev/null +++ b/e2e/tests/ui/filter-dialog.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "../../fixtures/electron"; + +test("telemetry filter dialog opens with correct title", async ({ page }) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading")).toHaveText( + "Filter telemetry packets", + ); +}); + +test("commands filter dialog opens with correct title", async ({ page }) => { + await page.getByTestId("filter-button-commands").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading")).toHaveText("Filter commands"); +}); + +test("filter dialog select all and clear all buttons work", async ({ + page, +}) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + + await dialog.getByTestId("filter-clear-all").click(); + await dialog.getByTestId("filter-select-all").click(); + + // Dialog should still be open after interactions + await expect(dialog).toBeVisible(); +}); + +test("filter dialog closes on overlay click", async ({ page }) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + + // Press Escape to close + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); +}); diff --git a/e2e/tests/ui/mode-badge.test.ts b/e2e/tests/ui/mode-badge.test.ts new file mode 100644 index 000000000..70bcf5c5e --- /dev/null +++ b/e2e/tests/ui/mode-badge.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "../../fixtures/electron"; + +const VALID_MODES = ["mock", "active", "mock-active", "loading", "error"]; + +test("mode badge is visible with a valid mode", async ({ page }) => { + const badge = page.getByTestId("mode-badge"); + await expect(badge).toBeVisible(); + + const mode = await badge.getAttribute("data-mode"); + expect(VALID_MODES).toContain(mode); +}); + +test("mode badge reaches active mode", async ({ page }) => { + await expect(page.getByTestId("mode-badge")).toHaveAttribute( + "data-mode", + "active", + ); +}); + diff --git a/e2e/tests/ui/startup.test.ts b/e2e/tests/ui/startup.test.ts new file mode 100644 index 000000000..4bbc087ac --- /dev/null +++ b/e2e/tests/ui/startup.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "../../fixtures/electron"; + +test("backend logs window opens with correct title", async ({ logPage }) => { + await expect(logPage).toHaveTitle("Backend Logs"); +}); + +test("control station window opens with correct title", async ({ page }) => { + await expect(page).toHaveTitle("Hyperloop Testing View"); +}); diff --git a/electron-app/main.js b/electron-app/main.js index ae651860a..cc9be7bf1 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -17,20 +17,11 @@ import { createWindow } from "./src/windows/mainWindow.js"; const { autoUpdater } = pkg; -// Disable sandbox for Linux +// Disable sandbox on Linux — sandbox restrictions vary across distros +// (AppArmor on Ubuntu, SELinux on Fedora, etc.) and this is an internal +// app where all content is trusted. if (process.platform === "linux") { - try { - const userns = fs - .readFileSync("/proc/sys/kernel/unprivileged_userns_clone", "utf8") - .trim(); - if (userns === "0") { - app.commandLine.appendSwitch("no-sandbox"); - } - } catch (e) {} - - if (process.getuid && process.getuid() === 0) { - app.commandLine.appendSwitch("no-sandbox"); - } + app.commandLine.appendSwitch("no-sandbox"); } // Setup IPC handlers for renderer process communication diff --git a/electron-app/package.json b/electron-app/package.json index 2d936c068..0a1fc3b24 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -57,7 +57,7 @@ "owner": "Hyperloop-UPV", "repo": "software" }, - "productName": "Hyperloop-Ctrl", + "productName": "Control-Station", "directories": { "output": "dist" }, diff --git a/electron-app/preload.js b/electron-app/preload.js index 5c3d5c023..5fda40215 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -36,6 +36,8 @@ contextBridge.exposeInMainWorld("electronAPI", { importConfig: () => ipcRenderer.invoke("import-config"), // Open folder selection dialog selectFolder: () => ipcRenderer.invoke("select-folder"), + // Open a folder path in the OS file explorer + openFolder: (path) => ipcRenderer.invoke("open-folder", path), // Receive log message from backend onLog: (callback) => { const listener = (_event, value) => callback(value); diff --git a/electron-app/src/config/configInstance.js b/electron-app/src/config/configInstance.js index 33467fb80..ec3c9339e 100644 --- a/electron-app/src/config/configInstance.js +++ b/electron-app/src/config/configInstance.js @@ -42,9 +42,9 @@ async function getConfigManager() { ); logger.config.info("ConfigManager initialized"); - logger.config.path("User config", userConfigPath); - logger.config.path("User version config", versionFilePath); - logger.config.path("Template path", templatePath); + logger.config.debug("User config", userConfigPath); + logger.config.debug("User version config", versionFilePath); + logger.config.debug("Template path", templatePath); } // Return the singleton instance diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 79d8c1075..269ea5235 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -7,13 +7,15 @@ * - Folder selection dialogs */ -import { dialog, ipcMain } from "electron"; +import { dialog, ipcMain, shell } from "electron"; +import fs from "fs"; +import { isAbsolute, join } from "path"; import { importConfig, readConfig, writeConfig, } from "../config/configInstance.js"; -import { restartBackend } from "../processes/backend.js"; +import { getBackendWorkingDir, restartBackend } from "../processes/backend.js"; import { logger } from "../utils/logger.js"; import { getCurrentView, @@ -136,6 +138,28 @@ function setupIpcHandlers() { throw error; } }); + + /** + * @event open-folder + * @async + * @description Opens the specified folder path in the OS file explorer. + * @param {import("electron").IpcMainInvokeEvent} event - The IPC event object. + * @param {string} folderPath - The folder path to open. + * @returns {Promise} + * @throws {Error} If opening the folder fails. + */ + ipcMain.handle("open-folder", async (event, folderPath) => { + try { + const resolvedPath = isAbsolute(folderPath) + ? folderPath + : join(getBackendWorkingDir(), folderPath); + const loggerPath = join(resolvedPath, "logger"); + await shell.openPath(fs.existsSync(loggerPath) ? loggerPath : resolvedPath); + } catch (error) { + logger.electron.error("Error opening folder:", error); + throw error; + } + }); } export { setupIpcHandlers }; diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 92dffe7eb..b0894ca31 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -75,13 +75,22 @@ async function startBackend(logWindow = null) { // Log stdout output from backend backendProcess.stdout.on("data", (data) => { - logger.backend.info(`${data.toString().trim()}`); + const text = data.toString().trim(); + logger.backend.info(text); // Send log message to log window if (currentLogWindow && !currentLogWindow.isDestroyed()) { - const htmlData = convert.toHtml(data.toString().trim()); + const htmlData = convert.toHtml(text); currentLogWindow.webContents.send("log", htmlData); } + + // Resolve as soon as the HTTP server confirms it is listening. + // Matches: "INF ... > http server listening localAddr=..." + if (text.includes("http server listening")) { + logger.backend.info("Backend ready (HTTP server listening)"); + clearTimeout(startupTimer); + resolve(backendProcess); + } }); // Capture stderr output (where Go errors/panics are written) @@ -130,10 +139,13 @@ async function startBackend(logWindow = null) { backendProcess = null; }); - // If the backend didn't fail in this period of time, resolve the promise + // Fallback: if the ready message never appears, resolve anyway after timeout const startupTimer = setTimeout(() => { + logger.backend.warning( + "Backend ready signal not received - resolving after timeout", + ); resolve(backendProcess); - }, 2000); + }, 5000); }); } @@ -200,4 +212,10 @@ async function restartBackend() { } } -export { restartBackend, startBackend, stopBackend }; +function getBackendWorkingDir() { + return !app.isPackaged + ? path.join(appPath, "..", "backend", "cmd") + : path.dirname(getUserConfigPath()); +} + +export { getBackendWorkingDir, restartBackend, startBackend, stopBackend }; diff --git a/electron-app/src/utils/__tests__/getAppPath.test.js b/electron-app/src/utils/__tests__/getAppPath.test.js new file mode 100644 index 000000000..c931595be --- /dev/null +++ b/electron-app/src/utils/__tests__/getAppPath.test.js @@ -0,0 +1,34 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getAppPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getAppPath", () => { + it("dev: returns the electron-app root (2 levels up from src/utils)", () => { + expect(path.normalize(getAppPath())).toBe( + path.normalize(ELECTRON_APP_ROOT), + ); + }); + + it("prod: returns app.getAppPath()", () => { + app.isPackaged = true; + app.getAppPath.mockReturnValue("/prod/app"); + + expect(getAppPath()).toBe("/prod/app"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getBinaryPath.test.js b/electron-app/src/utils/__tests__/getBinaryPath.test.js new file mode 100644 index 000000000..5e0742b0e --- /dev/null +++ b/electron-app/src/utils/__tests__/getBinaryPath.test.js @@ -0,0 +1,68 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT, mockArch, mockPlatform } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getBinaryPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; + mockPlatform("linux"); + mockArch("x64"); + process.resourcesPath = "/mock/resources"; +}); + +describe("getBinaryPath", () => { + it("dev win32/x64: appends .exe and maps to windows-amd64", () => { + mockPlatform("win32"); + mockArch("x64"); + + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-windows-amd64.exe"), + ); + }); + + it("dev darwin/arm64: no .exe extension, maps to darwin-arm64", () => { + mockPlatform("darwin"); + mockArch("arm64"); + + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-darwin-arm64"), + ); + }); + + it("dev linux/x64: no .exe extension, maps to linux-amd64", () => { + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-linux-amd64"), + ); + }); + + it("prod: uses process.resourcesPath instead of app root", () => { + app.isPackaged = true; + + expect(getBinaryPath("backend")).toBe( + path.join("/mock/resources", "binaries", "backend-linux-amd64"), + ); + }); + + it("unknown platform: falls back to raw platform name", () => { + mockPlatform("freebsd"); + + expect(getBinaryPath("backend")).toContain("backend-freebsd-"); + }); + + it("unknown arch: falls back to raw arch name", () => { + mockArch("mips"); + + expect(getBinaryPath("backend")).toContain("-mips"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getTemplatePath.test.js b/electron-app/src/utils/__tests__/getTemplatePath.test.js new file mode 100644 index 000000000..33ac13424 --- /dev/null +++ b/electron-app/src/utils/__tests__/getTemplatePath.test.js @@ -0,0 +1,36 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getTemplatePath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; + process.resourcesPath = "/mock/resources"; +}); + +describe("getTemplatePath", () => { + it("dev: returns dev-config.toml in backend/cmd/", () => { + expect(getTemplatePath()).toBe( + path.join(ELECTRON_APP_ROOT, "..", "backend", "cmd", "dev-config.toml"), + ); + }); + + it("prod: returns config.toml under process.resourcesPath", () => { + app.isPackaged = true; + + expect(getTemplatePath()).toBe( + path.join("/mock/resources", "config.toml"), + ); + }); +}); diff --git a/electron-app/src/utils/__tests__/getUserConfigPath.test.js b/electron-app/src/utils/__tests__/getUserConfigPath.test.js new file mode 100644 index 000000000..28d32d18c --- /dev/null +++ b/electron-app/src/utils/__tests__/getUserConfigPath.test.js @@ -0,0 +1,37 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getUserConfigPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getUserConfigPath", () => { + it("dev: returns config.toml in electron-app root", () => { + expect(getUserConfigPath()).toBe( + path.join(ELECTRON_APP_ROOT, "config.toml"), + ); + }); + + it("prod: returns config.toml under userData/configs/", () => { + app.isPackaged = true; + app.getPath.mockReturnValue("/mock/userData"); + + expect(getUserConfigPath()).toBe( + path.join("/mock/userData", "configs", "config.toml"), + ); + expect(app.getPath).toHaveBeenCalledWith("userData"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getVersionFilePath.test.js b/electron-app/src/utils/__tests__/getVersionFilePath.test.js new file mode 100644 index 000000000..a91333de5 --- /dev/null +++ b/electron-app/src/utils/__tests__/getVersionFilePath.test.js @@ -0,0 +1,37 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getVersionFilePath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getVersionFilePath", () => { + it("dev: returns version.toml in electron-app root", () => { + expect(getVersionFilePath()).toBe( + path.join(ELECTRON_APP_ROOT, "version.toml"), + ); + }); + + it("prod: returns version.toml directly under userData/", () => { + app.isPackaged = true; + app.getPath.mockReturnValue("/mock/userData"); + + expect(getVersionFilePath()).toBe( + path.join("/mock/userData", "version.toml"), + ); + expect(app.getPath).toHaveBeenCalledWith("userData"); + }); +}); diff --git a/electron-app/src/utils/__tests__/helpers.js b/electron-app/src/utils/__tests__/helpers.js new file mode 100644 index 000000000..9a2ece5a2 --- /dev/null +++ b/electron-app/src/utils/__tests__/helpers.js @@ -0,0 +1,26 @@ +import { dirname } from "path"; +import path from "path"; +import { fileURLToPath } from "url"; + +// electron-app root is 3 levels up from this file: +// electron-app/src/utils/__tests__/helpers.js -> ../../../ = electron-app/ +export const ELECTRON_APP_ROOT = path.resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", +); + +export function mockPlatform(platform) { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function mockArch(arch) { + Object.defineProperty(process, "arch", { + value: arch, + configurable: true, + }); +} diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 0e994eeac..0bf125b85 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -54,11 +54,16 @@ function createWindow(screenWidth, screenHeight) { const menu = createMenu(mainWindow); mainWindow.setMenu(menu); - // Open DevTools in development mode - if (!app.isPackaged) { + // Open DevTools in development mode (skip in test env to keep window order predictable) + if (!app.isPackaged && process.env.NODE_ENV !== "test") { mainWindow.webContents.openDevTools(); } + // Quit the app when main window is closed + mainWindow.on("close", () => { + app.quit(); + }); + // Clear window reference when closed mainWindow.on("closed", () => { mainWindow = null; diff --git a/frontend/README.md b/frontend/README.md index de434e676..258ca1b17 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -26,6 +26,7 @@ The frontend is organized as 6 workspaces out of 9 in the whole monorepo, divide - **Zustand** for state management - **React Router** for navigation - **Radix UI / shadcn/ui** for UI components +- **RxJS** for reactive data streams - **WebSocket** for real-time backend communication - **@dnd-kit** for drag-and-drop functionality diff --git a/frontend/frontend-kit/ui/src/icons/files.ts b/frontend/frontend-kit/ui/src/icons/files.ts index a15685d9f..c9e221541 100644 --- a/frontend/frontend-kit/ui/src/icons/files.ts +++ b/frontend/frontend-kit/ui/src/icons/files.ts @@ -1 +1,5 @@ -export { Folder, Trash2 } from "lucide-react"; +export { + Folder, + FolderOpen, + Trash2, +} from "lucide-react"; diff --git a/frontend/testing-view/.env.e2e b/frontend/testing-view/.env.e2e new file mode 100644 index 000000000..38345cea9 --- /dev/null +++ b/frontend/testing-view/.env.e2e @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://127.0.0.1:4000/backend diff --git a/frontend/testing-view/README.md b/frontend/testing-view/README.md index fc32fc980..f1a35b067 100644 --- a/frontend/testing-view/README.md +++ b/frontend/testing-view/README.md @@ -1,73 +1,49 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from "eslint-plugin-react-x"; -import reactDom from "eslint-plugin-react-dom"; - -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs["recommended-typescript"], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` +# Testing View + +The Testing View is the web interface used during vehicle testing sessions. It provides real-time telemetry charts and a filtering system for monitoring packet data from the pod. + +It is built with **React**, **TypeScript**, and **Vite**, and runs embedded inside the Hyperloop Control Station Electron app. + +--- + +## Features + +### Workspaces + +Workspaces are named tabs that each hold their own independent set of charts. You can create, rename, and delete workspaces, and switch between them at any time. The active workspace and its charts are persisted across sessions. + +### Charts + +The charts panel displays live telemetry data as line charts within the active workspace. You can add, remove, and reorder charts via drag and drop. Each chart supports multiple data series and a configurable history limit that controls how many data points are kept in memory. + +### Filtering + +The filtering system lets you select which telemetry packets and commands are visible. Filters are organized in a tree matching the packet catalog structure, with search, select all, and clear all controls. + +### Settings + +A settings dialog exposes runtime configuration for the vehicle connection, including vehicle board selection, ADJ branch, TCP/TFTP connection parameters, BLCU addresses, and logging options (time unit and output path). + +### Key Bindings + +The key bindings system lets you assign keyboard shortcuts to commands sent to the vehicle, as well as special built-in actions like starting, stopping, or toggling the logger. + +--- + +## Scripts + +| Script | Description | +| :---------------- | :---------------------------------------------- | +| `pnpm dev` | Start the Vite dev server | +| `pnpm build` | Type-check and build for production | +| `pnpm build:e2e` | Build in e2e mode (used by the `e2e` workspace) | +| `pnpm preview` | Preview the production build | +| `pnpm lint` | Run ESLint | +| `pnpm test` | Run unit tests once (Vitest) | +| `pnpm test:watch` | Run unit tests in watch mode | + +--- + +## Tests + +Unit tests are written with **Vitest**. The charts store, filtering store, and utility functions are covered. diff --git a/frontend/testing-view/index.html b/frontend/testing-view/index.html index 09e38dc71..5f7ed82d4 100644 --- a/frontend/testing-view/index.html +++ b/frontend/testing-view/index.html @@ -4,7 +4,7 @@ - Testing View + Hyperloop Testing View
diff --git a/frontend/testing-view/package.json b/frontend/testing-view/package.json index b6f2627dd..cf135f1cb 100644 --- a/frontend/testing-view/package.json +++ b/frontend/testing-view/package.json @@ -10,6 +10,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "build:e2e": "tsc -b && vite build --mode e2e", "test": "vitest run", "test:watch": "vitest" }, diff --git a/frontend/testing-view/src/components/header/ModeBadge.tsx b/frontend/testing-view/src/components/header/ModeBadge.tsx index d96e08d6f..d4ec401b3 100644 --- a/frontend/testing-view/src/components/header/ModeBadge.tsx +++ b/frontend/testing-view/src/components/header/ModeBadge.tsx @@ -31,6 +31,8 @@ export const ModeBadge = () => { return (
) => { + const { openFolder } = useOpenFolder(); + const handleBrowse = async () => { - // Accessing the Electron API exposed via preload script - const path = await window.electronAPI?.selectFolder(); + if (!window.electronAPI) { + logger.testingView.warn("electronAPI is not available"); + return; + } + const path = await window.electronAPI.selectFolder(); if (path) { onChange(path); } @@ -20,6 +28,9 @@ export const PathField = ({ field, value, onChange }: FieldProps) => { placeholder={field.placeholder || "No path selected"} className="bg-muted/50" /> + diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index 7a37b7e98..33db5109d 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -13,6 +13,7 @@ export const SettingsDialog = () => { const isSettingsOpen = useStore((s) => s.isSettingsOpen); const setSettingsOpen = useStore((s) => s.setSettingsOpen); const setRestarting = useStore((s) => s.setRestarting); + const setConfig = useStore((s) => s.setConfig); const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); const [isSaving, startSaving] = useTransition(); @@ -24,6 +25,7 @@ export const SettingsDialog = () => { try { const config = await window.electronAPI.getConfig(); setLocalConfig(config); + setConfig(config); setIsSynced(true); } catch (error) { console.error("Error loading config:", error); @@ -37,24 +39,33 @@ export const SettingsDialog = () => { } }; - const loadBranches = () => { + const loadBranches = (signal: AbortSignal) => { startBranchesTransition(async () => { try { const res = await fetch( "https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100", + { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }, ); const data = await res.json(); setBranches(data.map((b: { name: string }) => b.name)); } catch (error) { - console.error("Error loading branches:", error); + if ( + error instanceof Error && + error.name !== "AbortError" && + error.name !== "TimeoutError" + ) { + console.error("Error loading branches:", error); + } } }); }; useEffect(() => { if (isSettingsOpen) { + const controller = new AbortController(); loadConfig(); - loadBranches(); + loadBranches(controller.signal); + return () => controller.abort(); } }, [isSettingsOpen]); diff --git a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx index ca788495a..296624a64 100644 --- a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx +++ b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx @@ -96,6 +96,8 @@ export const ChartSurface = memo( .getPropertyValue(varName) .trim(); + const enumOptions = series[0]?.enumOptions; + const opts: uPlot.Options = { width: containerRef.current.clientWidth - 32, height: config.DEFAULT_CHART_HEIGHT, @@ -106,14 +108,16 @@ export const ChartSurface = memo( padding: [20, 10, 5, 15], scales: { x: { time: false }, - y: { - range: (_, min, max) => { - if (min === max) return [min - 1, max + 1]; - const span = max - min; - const buffer = span * 0.15; - return [min - buffer, max + buffer]; - }, - }, + y: enumOptions?.length + ? { range: () => [0, enumOptions.length - 1] } + : { + range: (_, min, max) => { + if (min === max) return [min - 1, max + 1]; + const span = max - min; + const buffer = span * 0.15; + return [min - buffer, max + buffer]; + }, + }, }, series: [ {}, @@ -141,7 +145,12 @@ export const ChartSurface = memo( stroke: getStyle("--muted-foreground"), grid: { stroke: getStyle("--border") }, font: "10px Archivo", - size: 40, + size: enumOptions?.length ? 80 : 40, + ...(enumOptions?.length && { + splits: () => enumOptions.map((_, i) => i), + values: (_u: uPlot, vals: number[]) => + vals.map((v) => enumOptions[v] ?? ""), + }), }, ], cursor: { drag: { setScale: true, x: true, y: true } }, @@ -184,6 +193,7 @@ export const ChartSurface = memo( const m = pkt?.measurementUpdates?.[p.variable]; if (typeof m === "boolean") return m ? 1 : 0; if (typeof m === "object" && m !== null && "last" in m) return m.last; + if (typeof m === "string") return p.enumOptions?.indexOf(m) ?? 0; return m ?? 0; }), }; diff --git a/frontend/testing-view/src/features/charts/components/SortableChart.tsx b/frontend/testing-view/src/features/charts/components/SortableChart.tsx index 76efbfc37..f9353058b 100644 --- a/frontend/testing-view/src/features/charts/components/SortableChart.tsx +++ b/frontend/testing-view/src/features/charts/components/SortableChart.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { canAddSeriesToChart } from "../../../lib/utils"; import type { WorkspaceChartSeries } from "../types/charts"; import { TelemetryChart } from "./TelemetryChart"; @@ -24,6 +25,10 @@ export function SortableChart({ id, series }: SortableChartProps) { }); const isVariableOver = isOver && active?.data.current?.type === "variable"; + const draggedIsEnum = + (active?.data.current?.variableEnumOptions?.length ?? 0) > 0; + const isIncompatibleDrop = + isVariableOver && !canAddSeriesToChart(series, draggedIsEnum); const style = { transform: CSS.Transform.toString(transform), @@ -37,7 +42,8 @@ export function SortableChart({ id, series }: SortableChartProps) { id={id} series={series} isDragging={false} - isOver={isVariableOver} + isOver={isVariableOver && !isIncompatibleDrop} + isIncompatibleDrop={isIncompatibleDrop} dragAttributes={attributes} dragListeners={listeners} /> diff --git a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx index 81355f499..1cac96207 100644 --- a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx +++ b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx @@ -14,6 +14,7 @@ interface TelemetryChartProps { series: WorkspaceChartSeries[]; isDragging: boolean; isOver?: boolean; + isIncompatibleDrop?: boolean; dragAttributes?: DraggableAttributes; dragListeners?: SyntheticListenerMap; } @@ -34,6 +35,7 @@ export const TelemetryChart = ({ series, isDragging, isOver, + isIncompatibleDrop, dragAttributes, dragListeners, }: TelemetryChartProps) => { @@ -74,9 +76,11 @@ export const TelemetryChart = ({ return (
@@ -101,6 +105,14 @@ export const TelemetryChart = ({
+ {isIncompatibleDrop && ( +
+ + Cannot mix enum and numeric series + +
+ )} + { if (u.series[i + 1].show) { row.classList.remove("hidden"); row.classList.add("flex"); - vals[i].textContent = u.data[i + 1][idx]?.toFixed(2) ?? "0.00"; + const rawVal = u.data[i + 1][idx]; + const enumOptions = series[i].enumOptions; + if (enumOptions?.length && rawVal != null) { + vals[i].textContent = enumOptions[Math.round(rawVal)] ?? String(rawVal); + } else { + vals[i].textContent = rawVal?.toFixed(2) ?? "0.00"; + } anyVisible = true; } else { row.classList.add("hidden"); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts new file mode 100644 index 000000000..bb9cc6203 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { config } from "../../../../../config"; +import { WORKSPACE_ID, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("addChart", () => { + it("adds a chart to the specified workspace", () => { + store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(1); + }); + + it("returns the new chart's ID", () => { + const id = store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts[WORKSPACE_ID][0].id).toBe(id); + }); + + it("initializes the chart with an empty series array", () => { + const id = store.getState().addChart(WORKSPACE_ID); + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === id); + + expect(chart?.series).toStrictEqual([]); + }); + + it("initializes the chart with the default history limit", () => { + const id = store.getState().addChart(WORKSPACE_ID); + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === id); + + expect(chart?.historyLimit).toBe(config.DEFAULT_CHART_HISTORY_LIMIT); + }); + + it("does not affect other workspaces", () => { + const chartsBefore = store.getState().charts["workspace-2"]; + store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts["workspace-2"]).toStrictEqual(chartsBefore); + }); + + it("each call adds a distinct chart", () => { + const id1 = store.getState().addChart(WORKSPACE_ID); + const id2 = store.getState().addChart(WORKSPACE_ID); + + expect(id1).not.toBe(id2); + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(2); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts new file mode 100644 index 000000000..271cd06c4 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let chartId: string; + +beforeEach(() => { + store = createTestStore(); + chartId = addChart(store); +}); + +describe("setChartHistoryLimit", () => { + it("updates the history limit for the correct chart", () => { + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.historyLimit).toBe(500); + }); + + it("does not affect other charts in the same workspace", () => { + const otherId = addChart(store); + const otherLimitBefore = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId)?.historyLimit; + + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.historyLimit).toBe(otherLimitBefore); + }); + + it("does not affect charts in other workspaces", () => { + const otherChartId = addChart(store, "workspace-2"); + const otherLimitBefore = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId)?.historyLimit; + + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const otherChart = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId); + expect(otherChart?.historyLimit).toBe(otherLimitBefore); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts new file mode 100644 index 000000000..83f65c2c0 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { SERIES_A, SERIES_B, SERIES_ENUM, WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let chartId: string; + +beforeEach(() => { + store = createTestStore(); + chartId = addChart(store); +}); + +// ─── addSeriesToChart ───────────────────────────────────────────────────────── + +describe("addSeriesToChart", () => { + it("adds a series to the correct chart", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toHaveLength(1); + expect(chart?.series[0]).toStrictEqual(SERIES_A); + }); + + it("appends multiple series in order", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_B); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toStrictEqual([SERIES_A, SERIES_B]); + }); + + it("supports enum series", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_ENUM); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series[0].enumOptions).toStrictEqual(["Idle", "Running", "Fault"]); + }); + + it("does not affect other charts in the same workspace", () => { + const otherId = addChart(store); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.series).toHaveLength(0); + }); + + it("does not affect charts in other workspaces", () => { + const otherChartId = addChart(store, "workspace-2"); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const otherChart = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId); + expect(otherChart?.series).toHaveLength(0); + }); +}); + +// ─── removeSeriesFromChart ──────────────────────────────────────────────────── + +describe("removeSeriesFromChart", () => { + beforeEach(() => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_B); + }); + + it("removes the series with the given variable name", () => { + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series.find((s) => s.variable === SERIES_A.variable)).toBeUndefined(); + }); + + it("keeps other series in the same chart", () => { + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toHaveLength(1); + expect(chart?.series[0]).toStrictEqual(SERIES_B); + }); + + it("does not affect other charts", () => { + const otherId = addChart(store); + store.getState().addSeriesToChart(WORKSPACE_ID, otherId, SERIES_A); + + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.series).toHaveLength(1); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts new file mode 100644 index 000000000..b62d4c3cb --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("clearCharts", () => { + it("removes all charts from the active workspace", () => { + addChart(store); + addChart(store); + + store.getState().clearCharts(); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(0); + }); + + it("does not affect charts in other workspaces", () => { + addChart(store, "workspace-2"); + addChart(store); + + store.getState().clearCharts(); + + expect(store.getState().charts["workspace-2"]).toHaveLength(1); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts new file mode 100644 index 000000000..b2b7c4977 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("getActiveWorkspaceCharts", () => { + it("returns the charts for the active workspace", () => { + const id = addChart(store); + + const charts = store.getState().getActiveWorkspaceCharts(); + expect(charts).toHaveLength(1); + expect(charts[0].id).toBe(id); + }); + + it("returns an empty array when the workspace has no charts", () => { + expect(store.getState().getActiveWorkspaceCharts()).toStrictEqual([]); + }); + + it("reflects the active workspace — switching workspaces returns different charts", () => { + addChart(store); + + const workspace2 = store.getState().workspaces[1]; + store.getState().setActiveWorkspace(workspace2); + + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(0); + }); + + it("updates after a chart is added", () => { + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(0); + + addChart(store); + + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(1); + }); + + it("updates after a chart is removed", () => { + const id = addChart(store); + store.getState().removeChart(WORKSPACE_ID, id); + + expect(store.getState().getActiveWorkspaceCharts()).toStrictEqual([]); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts b/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts new file mode 100644 index 000000000..7740e369b --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { createChartsSlice } from "../chartsSlice"; +import { createAppSlice } from "../../../../store/slices/appSlice"; +import { createCatalogSlice } from "../../../../store/slices/catalogSlice"; +import { createConnectionsSlice } from "../../../../store/slices/connectionsSlice"; +import { createMessagesSlice } from "../../../../store/slices/messagesSlice"; +import { createTelemetrySlice } from "../../../../store/slices/telemetrySlice"; +import { createRightSidebarSlice } from "../../../workspace/store/rightSidebarSlice"; +import { createWorkspacesSlice } from "../../../workspace/store/workspacesSlice"; +import { createFilteringSlice } from "../../../filtering/store/filteringSlice"; +import type { Store } from "../../../../store/store"; +import type { WorkspaceChartSeries } from "../../types/charts"; + +export const createTestStore = () => + create()((...a) => ({ + ...createAppSlice(...a), + ...createCatalogSlice(...a), + ...createWorkspacesSlice(...a), + ...createTelemetrySlice(...a), + ...createRightSidebarSlice(...a), + ...createConnectionsSlice(...a), + ...createMessagesSlice(...a), + ...createChartsSlice(...a), + ...createFilteringSlice(...a), + })); + +export const WORKSPACE_ID = "workspace-1"; + +export const SERIES_A: WorkspaceChartSeries = { + packetId: 1, + variable: "speed", +}; + +export const SERIES_B: WorkspaceChartSeries = { + packetId: 2, + variable: "temperature", +}; + +export const SERIES_ENUM: WorkspaceChartSeries = { + packetId: 3, + variable: "state", + enumOptions: ["Idle", "Running", "Fault"], +}; + +/** Adds a chart to the given workspace and returns its ID. */ +export function addChart( + store: ReturnType, + workspaceId = WORKSPACE_ID, +) { + return store.getState().addChart(workspaceId); +} diff --git a/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts new file mode 100644 index 000000000..06e23b6c8 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("removeChart", () => { + it("removes the correct chart", () => { + const id = addChart(store); + store.getState().removeChart(WORKSPACE_ID, id); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(0); + }); + + it("does not remove other charts in the same workspace", () => { + const id1 = addChart(store); + const id2 = addChart(store); + + store.getState().removeChart(WORKSPACE_ID, id1); + + const remaining = store.getState().charts[WORKSPACE_ID]; + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(id2); + }); + + it("does not affect charts in other workspaces", () => { + const id = addChart(store, "workspace-2"); + addChart(store); + + store.getState().removeChart(WORKSPACE_ID, store.getState().charts[WORKSPACE_ID][0].id); + + expect(store.getState().charts["workspace-2"].find((c) => c.id === id)).toBeDefined(); + }); + + it("does nothing when the chart ID does not exist", () => { + addChart(store); + const countBefore = store.getState().charts[WORKSPACE_ID].length; + + store.getState().removeChart(WORKSPACE_ID, "non-existent-id"); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(countBefore); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts new file mode 100644 index 000000000..993cd65e2 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let id1: string; +let id2: string; +let id3: string; + +beforeEach(() => { + store = createTestStore(); + id1 = addChart(store); + id2 = addChart(store); + id3 = addChart(store); +}); + +const chartIds = () => + store.getState().charts[WORKSPACE_ID].map((c) => c.id); + +describe("reorderCharts", () => { + it("moves a chart forward in the list", () => { + store.getState().reorderCharts(WORKSPACE_ID, 0, 2); + + expect(chartIds()).toStrictEqual([id2, id3, id1]); + }); + + it("moves a chart backward in the list", () => { + store.getState().reorderCharts(WORKSPACE_ID, 2, 0); + + expect(chartIds()).toStrictEqual([id3, id1, id2]); + }); + + it("is a no-op when old and new index are the same", () => { + store.getState().reorderCharts(WORKSPACE_ID, 1, 1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does nothing when oldIndex is negative", () => { + store.getState().reorderCharts(WORKSPACE_ID, -1, 1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does nothing when newIndex is negative", () => { + store.getState().reorderCharts(WORKSPACE_ID, 0, -1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does not affect other workspaces", () => { + const otherId = addChart(store, "workspace-2"); + store.getState().reorderCharts(WORKSPACE_ID, 0, 2); + + expect(store.getState().charts["workspace-2"].map((c) => c.id)).toStrictEqual([otherId]); + }); +}); diff --git a/frontend/testing-view/src/features/charts/types/charts.ts b/frontend/testing-view/src/features/charts/types/charts.ts index b1277bda0..f446408b8 100644 --- a/frontend/testing-view/src/features/charts/types/charts.ts +++ b/frontend/testing-view/src/features/charts/types/charts.ts @@ -4,6 +4,7 @@ export interface WorkspaceChartSeries { packetId: number; variable: string; + enumOptions?: string[]; } /** diff --git a/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx b/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx index ad74f5175..150d4098c 100644 --- a/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx +++ b/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx @@ -35,17 +35,17 @@ export const FilterDialog = ({ }: FilterDialogProps) => { return ( - + {title} {description && {description}}
- -
diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts new file mode 100644 index 000000000..bd3eb9a08 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { BOARDS, createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── isItemExpanded / toggleExpandedItem ────────────────────────────────────── + +describe("isItemExpanded", () => { + it("is false by default", () => { + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + false, + ); + }); +}); + +describe("toggleExpandedItem", () => { + it("expands a collapsed item", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + true, + ); + }); + + it("collapses an already-expanded item", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + false, + ); + }); + + it("does not expand other items in the same scope", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "PCU")).toBe( + false, + ); + }); + + it("is scoped — expanding in 'telemetry' does not affect 'commands'", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("commands", "board", "BCU")).toBe( + false, + ); + }); +}); + +// ─── getFlattenedRows ───────────────────────────────────────────────────────── + +describe("getFlattenedRows", () => { + it("returns only board rows when all boards are collapsed", () => { + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows.every((r) => r.type === "board")).toBe(true); + expect(rows).toHaveLength(2); + }); + + it("returns correct board row shape", () => { + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU", count: 2 }); + expect(rows[1]).toMatchObject({ type: "board", id: "PCU", count: 1 }); + }); + + it("includes packet rows under an expanded board", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + const types = rows.map((r) => r.type); + + expect(types).toContain("packet"); + // BCU board header + 2 BCU packets + PCU board header + expect(rows).toHaveLength(4); + }); + + it("packet rows appear after their board header", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU" }); + expect(rows[1].type).toBe("packet"); + expect(rows[2].type).toBe("packet"); + expect(rows[3]).toMatchObject({ type: "board", id: "PCU" }); + }); + + it("skips boards with no filtered items", () => { + store.getState().toggleCategoryFilter("telemetry", "PCU", false); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows.some((r) => r.id === "PCU")).toBe(false); + }); + + it("reflects commands catalog when scope is 'commands'", () => { + const rows = store.getState().getFlattenedRows("commands", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU", count: 2 }); + expect(rows[1]).toMatchObject({ type: "board", id: "PCU", count: 1 }); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts new file mode 100644 index 000000000..0fde4a071 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── selectAllFilters ───────────────────────────────────────────────────────── + +describe("selectAllFilters", () => { + it("restores all command IDs after a clear", () => { + store.getState().clearFilters("commands"); + store.getState().selectAllFilters("commands"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual({ + BCU: [1, 2], + PCU: [3], + }); + }); + + it("restores all telemetry IDs after a clear", () => { + store.getState().clearFilters("telemetry"); + store.getState().selectAllFilters("telemetry"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual({ + BCU: [10, 20], + PCU: [30], + }); + }); + + it("does not affect the other scope", () => { + store.getState().clearFilters("commands"); + const telemetryBefore = store.getState().getActiveFilters("telemetry"); + + store.getState().selectAllFilters("commands"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual( + telemetryBefore, + ); + }); +}); + +// ─── clearFilters ───────────────────────────────────────────────────────────── + +describe("clearFilters", () => { + it("empties all command category arrays", () => { + store.getState().clearFilters("commands"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual({ + BCU: [], + PCU: [], + }); + }); + + it("empties all telemetry category arrays", () => { + store.getState().clearFilters("telemetry"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual({ + BCU: [], + PCU: [], + }); + }); + + it("does not affect the other scope", () => { + const commandsBefore = store.getState().getActiveFilters("commands"); + store.getState().clearFilters("telemetry"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual( + commandsBefore, + ); + }); +}); + +// ─── toggleItemFilter ───────────────────────────────────────────────────────── + +describe("toggleItemFilter", () => { + it("removes an ID that is currently selected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).not.toContain(1); + }); + + it("adds an ID that is not currently selected", () => { + store.getState().clearFilters("commands"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect(store.getState().getActiveFilters("commands")?.["BCU"]).toContain(1); + }); + + it("does not affect other categories", () => { + const pcuBefore = store.getState().getActiveFilters("commands")?.["PCU"]; + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect( + store.getState().getActiveFilters("commands")?.["PCU"], + ).toStrictEqual(pcuBefore); + }); + + it("does not affect the other scope", () => { + const telemetryBefore = store.getState().getActiveFilters("telemetry"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual( + telemetryBefore, + ); + }); +}); + +// ─── toggleCategoryFilter ───────────────────────────────────────────────────── + +describe("toggleCategoryFilter", () => { + it("checked=true selects all IDs in the commands category", () => { + store.getState().clearFilters("commands"); + store.getState().toggleCategoryFilter("commands", "BCU", true); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).toStrictEqual([1, 2]); + }); + + it("checked=true selects all IDs in the telemetry category", () => { + store.getState().clearFilters("telemetry"); + store.getState().toggleCategoryFilter("telemetry", "BCU", true); + + expect( + store.getState().getActiveFilters("telemetry")?.["BCU"], + ).toStrictEqual([10, 20]); + }); + + it("checked=false clears all IDs in the category", () => { + store.getState().toggleCategoryFilter("commands", "BCU", false); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).toStrictEqual([]); + }); + + it("does not affect other categories", () => { + const pcuBefore = store.getState().getActiveFilters("commands")?.["PCU"]; + store.getState().toggleCategoryFilter("commands", "BCU", false); + + expect( + store.getState().getActiveFilters("commands")?.["PCU"], + ).toStrictEqual(pcuBefore); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts new file mode 100644 index 000000000..060073e5c --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("filterDialog", () => { + it("is closed by default", () => { + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: false, + scope: null, + }); + }); + + it("opens with the given scope", () => { + store.getState().openFilterDialog("commands"); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: true, + scope: "commands", + }); + }); + + it("can open with 'telemetry' scope", () => { + store.getState().openFilterDialog("telemetry"); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: true, + scope: "telemetry", + }); + }); + + it("closes and clears the scope", () => { + store.getState().openFilterDialog("commands"); + store.getState().closeFilterDialog(); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: false, + scope: null, + }); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts new file mode 100644 index 000000000..6f68bba6a --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { BoardName } from "../../../../types/data/board"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── getFilteredItems ───────────────────────────────────────────────────────── + +describe("getFilteredItems", () => { + it("returns all command items when all are selected", () => { + const items = store.getState().getFilteredItems("commands"); + expect(items.map((i) => i.id)).toEqual(expect.arrayContaining([1, 2, 3])); + expect(items).toHaveLength(3); + }); + + it("returns all telemetry items when all are selected", () => { + const items = store.getState().getFilteredItems("telemetry"); + expect(items.map((i) => i.id)).toEqual( + expect.arrayContaining([10, 20, 30]), + ); + expect(items).toHaveLength(3); + }); + + it("returns only selected items when a partial filter is applied", () => { + store.getState().clearFilters("commands"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + const items = store.getState().getFilteredItems("commands"); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(1); + }); + + it("returns empty array when nothing is selected", () => { + store.getState().clearFilters("commands"); + expect(store.getState().getFilteredItems("commands")).toHaveLength(0); + }); + + it("does not mix commands and telemetry items", () => { + const commandItems = store.getState().getFilteredItems("commands"); + const telemetryItems = store.getState().getFilteredItems("telemetry"); + + const commandIds = commandItems.map((i) => i.id); + const telemetryIds = telemetryItems.map((i) => i.id); + + expect(commandIds.some((id) => telemetryIds.includes(id))).toBe(false); + }); +}); + +// ─── getFilteredItemsIds ────────────────────────────────────────────────────── + +describe("getFilteredItemsIds", () => { + it("returns flat list of all selected command IDs", () => { + const ids = store.getState().getFilteredItemsIds("commands"); + expect(ids).toEqual(expect.arrayContaining([1, 2, 3])); + }); + + it("returns flat list of all selected telemetry IDs", () => { + const ids = store.getState().getFilteredItemsIds("telemetry"); + expect(ids).toEqual(expect.arrayContaining([10, 20, 30])); + }); +}); + +describe("getFilteredItemsIdsByCategory", () => { + it("returns IDs only for the given board", () => { + expect( + store.getState().getFilteredItemsIdsByCategory("commands", "BCU"), + ).toStrictEqual([1, 2]); + expect( + store.getState().getFilteredItemsIdsByCategory("commands", "PCU"), + ).toStrictEqual([3]); + }); + + it("returns IDs from the correct catalog for telemetry", () => { + expect( + store.getState().getFilteredItemsIdsByCategory("telemetry", "BCU"), + ).toStrictEqual([10, 20]); + }); +}); + +// ─── getFilteredCount / getTotalCount ───────────────────────────────────────── + +describe("getFilteredCount", () => { + it("returns total number of selected command IDs", () => { + expect(store.getState().getFilteredCount("commands")).toBe(3); + }); + + it("decreases when an item is deselected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + expect(store.getState().getFilteredCount("commands")).toBe(2); + }); +}); + +describe("getFilteredCountByCategory", () => { + it("returns count per board for commands", () => { + expect(store.getState().getFilteredCountByCategory("commands", "BCU")).toBe( + 2, + ); + expect(store.getState().getFilteredCountByCategory("commands", "PCU")).toBe( + 1, + ); + }); + + it("returns count per board for telemetry", () => { + expect( + store.getState().getFilteredCountByCategory("telemetry", "BCU"), + ).toBe(2); + }); +}); + +describe("getTotalCount", () => { + it("returns total catalog size for commands", () => { + expect(store.getState().getTotalCount("commands")).toBe(3); + }); + + it("returns total catalog size for telemetry", () => { + expect(store.getState().getTotalCount("telemetry")).toBe(3); + }); +}); + +// ─── getSelectionState ──────────────────────────────────────────────────────── + +describe("getSelectionState", () => { + it("returns true when all items in the category are selected", () => { + expect(store.getState().getSelectionState("commands", "BCU")).toBe(true); + }); + + it("returns false when no items are selected", () => { + store.getState().clearFilters("commands"); + expect(store.getState().getSelectionState("commands", "BCU")).toBe(false); + }); + + it("returns 'indeterminate' when only some items are selected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + expect(store.getState().getSelectionState("commands", "BCU")).toBe( + "indeterminate", + ); + }); + + it("returns false for a board with no catalog items", () => { + expect( + store.getState().getSelectionState("commands", "EMPTY" as BoardName), + ).toBe(false); + }); + + it("works independently for commands and telemetry", () => { + store.getState().clearFilters("commands"); + + expect(store.getState().getSelectionState("commands", "BCU")).toBe(false); + expect(store.getState().getSelectionState("telemetry", "BCU")).toBe(true); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts new file mode 100644 index 000000000..e1b78faa6 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + COMMANDS_CATALOG, + TELEMETRY_CATALOG, + createTestStore, +} from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + store.getState().setCommandsCatalog(COMMANDS_CATALOG); + store.getState().setTelemetryCatalog(TELEMETRY_CATALOG); +}); + +describe("getCatalog", () => { + it("returns commandsCatalog for 'commands' scope", () => { + expect(store.getState().getCatalog("commands")).toBe( + store.getState().commandsCatalog, + ); + }); + + it("returns telemetryCatalog for 'telemetry' scope", () => { + expect(store.getState().getCatalog("telemetry")).toBe( + store.getState().telemetryCatalog, + ); + }); + + it("returns telemetryCatalog for 'logs' scope", () => { + expect(store.getState().getCatalog("logs")).toBe( + store.getState().telemetryCatalog, + ); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts b/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts new file mode 100644 index 000000000..6f727d3df --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { createAppSlice } from "../../../../store/slices/appSlice"; +import { createCatalogSlice } from "../../../../store/slices/catalogSlice"; +import { createConnectionsSlice } from "../../../../store/slices/connectionsSlice"; +import { createMessagesSlice } from "../../../../store/slices/messagesSlice"; +import { createTelemetrySlice } from "../../../../store/slices/telemetrySlice"; +import type { Store } from "../../../../store/store"; +import type { BoardName } from "../../../../types/data/board"; +import { createChartsSlice } from "../../../charts/store/chartsSlice"; +import { createRightSidebarSlice } from "../../../workspace/store/rightSidebarSlice"; +import { createWorkspacesSlice } from "../../../workspace/store/workspacesSlice"; +import { createFilteringSlice } from "../filteringSlice"; + +export const createTestStore = () => + create()((...a) => ({ + ...createAppSlice(...a), + ...createCatalogSlice(...a), + ...createWorkspacesSlice(...a), + ...createTelemetrySlice(...a), + ...createRightSidebarSlice(...a), + ...createConnectionsSlice(...a), + ...createMessagesSlice(...a), + ...createChartsSlice(...a), + ...createFilteringSlice(...a), + })); + +export const BOARDS: BoardName[] = ["BCU", "PCU"]; + +export const COMMANDS_CATALOG = { + BCU: [ + { id: 1, name: "cmd_start", label: "Start" }, + { id: 2, name: "cmd_stop", label: "Stop" }, + ], + PCU: [{ id: 3, name: "cmd_reset", label: "Reset" }], +}; + +export const TELEMETRY_CATALOG = { + BCU: [ + { id: 10, name: "bcu_speed", label: "Speed" }, + { id: 20, name: "bcu_temp", label: "Temperature" }, + ], + PCU: [{ id: 30, name: "pcu_voltage", label: "Voltage" }], +}; + +export function seedStore(store: ReturnType) { + const s = store.getState(); + s.setBoards(BOARDS); + s.setCommandsCatalog(COMMANDS_CATALOG); + s.setTelemetryCatalog(TELEMETRY_CATALOG); + s.initializeWorkspaceFilters(); +} diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts new file mode 100644 index 000000000..39ad25f78 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("initializeWorkspaceFilters", () => { + it("populates all default workspaces", () => { + seedStore(store); + const filters = store.getState().workspaceFilters; + + expect(Object.keys(filters)).toEqual( + expect.arrayContaining(["workspace-1", "workspace-2", "workspace-3"]), + ); + }); + + it("initializes commands filters with all command IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.commands["BCU"]).toEqual([1, 2]); + expect(filters.commands["PCU"]).toEqual([3]); + }); + + it("initializes telemetry filters with all telemetry IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.telemetry["BCU"]).toEqual([10, 20]); + expect(filters.telemetry["PCU"]).toEqual([30]); + }); + + it("initializes logs filters with all telemetry IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.logs["BCU"]).toEqual([10, 20]); + expect(filters.logs["PCU"]).toEqual([30]); + }); + + it("is idempotent — does not overwrite existing filters", () => { + seedStore(store); + store.getState().clearFilters("commands"); + const filtersAfterClear = store.getState().workspaceFilters; + + store.getState().initializeWorkspaceFilters(); + + expect(store.getState().workspaceFilters).toStrictEqual(filtersAfterClear); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/filteringSlice.ts b/frontend/testing-view/src/features/filtering/store/filteringSlice.ts index 58cefaccc..bbf530283 100644 --- a/frontend/testing-view/src/features/filtering/store/filteringSlice.ts +++ b/frontend/testing-view/src/features/filtering/store/filteringSlice.ts @@ -22,11 +22,6 @@ import type { } from "../types/filters"; export interface FilteringSlice { - /** Sidebar Navigation */ - activeTab: Record; - getActiveTab: () => SidebarTab; - setActiveTab: (tab: SidebarTab) => void; - filterDialog: { isOpen: boolean; scope: FilterScope | null; @@ -73,6 +68,7 @@ export interface FilteringSlice { category: BoardName, ) => number; getTotalCount: (scope: FilterScope) => number; + isAllSelected: (scope: FilterScope) => boolean; getSelectionState: (scope: FilterScope, category: BoardName) => CheckboxState; /** Virtualization & Expansion */ @@ -100,22 +96,6 @@ export const createFilteringSlice: StateCreator< [], FilteringSlice > = (set, get) => ({ - // Tabs (per workspace) - activeTab: {}, - getActiveTab: () => { - const activeWorkspaceId = get().getActiveWorkspaceId(); - if (!activeWorkspaceId) return "commands"; - return get().activeTab[activeWorkspaceId] || "commands"; - }, - setActiveTab: (tab) => { - const activeWorkspaceId = get().getActiveWorkspaceId(); - if (!activeWorkspaceId) return; - - set((state) => ({ - activeTab: { ...state.activeTab, [activeWorkspaceId]: tab }, - })); - }, - openFilterDialog: (scope: FilterScope) => set({ filterDialog: { isOpen: true, scope } }), closeFilterDialog: () => @@ -357,6 +337,10 @@ export const createFilteringSlice: StateCreator< const catalog = get().getCatalog(scope); return Object.values(catalog).reduce((acc, items) => acc + items.length, 0); }, + isAllSelected: (scope) => { + const total = get().getTotalCount(scope); + return total > 0 && get().getFilteredCount(scope) === total; + }, getSelectionState: (scope, category) => { const selectedCount = get().getFilteredCountByCategory(scope, category); diff --git a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx index 2db0457ce..a8eb99928 100644 --- a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx +++ b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx @@ -116,7 +116,16 @@ export const AddKeyBindingDialog = ({ setParameterValues((prev) => ({ ...prev, [fieldId]: value })); }; - const canSubmit = selectedCommandId !== null && capturedKey !== ""; + const hasInvalidNumericParams = + selectedCommand !== null && + selectedCommand !== undefined && + Object.entries(selectedCommand.fields).some( + ([key, field]) => + field.kind === "numeric" && isNaN(parseFloat(parameterValues[key])), + ); + + const canSubmit = + selectedCommandId !== null && capturedKey !== "" && !hasInvalidNumericParams; return ( diff --git a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts index 3fcd118c6..456f83cb6 100644 --- a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts @@ -100,8 +100,18 @@ export const useGlobalKeyBindings = () => { ]; } + const numericValue = + field.kind === "numeric" ? parseFloat(value) : value; + + if (field.kind === "numeric" && isNaN(numericValue as number)) { + logger.testingView.warn( + `Skipping command: numeric field "${fieldKey}" has no valid value`, + ); + return acc; + } + acc[fieldKey] = { - value: field.kind === "numeric" ? parseFloat(value) : value, + value: numericValue, isEnabled: true, type: field.type, }; diff --git a/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx b/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx index 3ea592df5..460db5e9e 100644 --- a/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx +++ b/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx @@ -1,8 +1,9 @@ import { Button, Separator } from "@workspace/ui"; -import { Settings2 } from "@workspace/ui/icons"; +import { FolderOpen, Settings2 } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import { LOGGER_CONTROL_CONFIG } from "../../../constants/loggerControlConfig"; import { useLogger } from "../../../hooks/useLogger"; +import { useOpenFolder } from "../../../hooks/useOpenFolder"; import { useStore } from "../../../store/store"; interface LoggerControlProps { @@ -11,8 +12,10 @@ interface LoggerControlProps { export const LoggerControl = ({ disabled }: LoggerControlProps) => { const { status, startLogging, stopLogging } = useLogger(); + const { openFolder } = useOpenFolder(); const openFilterDialog = useStore((s) => s.openFilterDialog); const filteredCount = useStore((state) => state.getFilteredCount("logs")); + const loggingPath = useStore((s) => s.config?.logging?.logging_path as string | undefined); const handleToggle = () => { if (status === "loading") return; @@ -66,6 +69,17 @@ export const LoggerControl = ({ disabled }: LoggerControlProps) => { {config.icon} + +
{/* Chart Picker */} - {!isEnum && ( -
- -
- )} +
+ +
{/* Live Value */} diff --git a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts index 821d99e59..1909a6ef8 100644 --- a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts +++ b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts @@ -6,6 +6,7 @@ import { useSensors, } from "@dnd-kit/core"; import { useState } from "react"; +import { canAddSeriesToChart } from "../../../lib/utils"; import { useStore } from "../../../store/store"; import type { DndActiveData } from "../types/dndData"; @@ -34,12 +35,17 @@ export function useDnd() { // Logic for adding a variable to a chart if (active.data.current?.type === "variable") { const chartId = over.data.current?.chartId; - const { packetId, variableId } = active.data.current; + const { packetId, variableId, variableEnumOptions } = + active.data.current; + const incomingIsEnum = (variableEnumOptions?.length ?? 0) > 0; // Add a variable to an existing chart if (chartId) { + const chart = charts.find((c) => c.id === chartId); + if (!canAddSeriesToChart(chart?.series ?? [], incomingIsEnum)) return; addSeries(activeWorkspaceId, chartId, { packetId, variable: variableId, + enumOptions: variableEnumOptions, }); // Add a new chart to the main panel } else if (over.id === "main-panel-droppable") { @@ -47,6 +53,7 @@ export function useDnd() { addSeries(activeWorkspaceId, newChartId, { packetId, variable: variableId, + enumOptions: variableEnumOptions, }); } } diff --git a/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts b/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts index 55d39eecd..cd9764fc0 100644 --- a/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts +++ b/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts @@ -3,7 +3,6 @@ import { createFullFilter } from "../../../lib/utils"; import type { Store } from "../../../store/store"; import type { KeyBinding } from "../../keyBindings/types/keyBinding"; import { DEFAULT_WORKSPACES } from "../constants/defaultWorkspaces"; -import type { SidebarTab } from "../types/sidebar"; import type { Workspace } from "../types/workspace"; export interface WorkspacesSlice { @@ -79,12 +78,6 @@ export const createWorkspacesSlice: StateCreator< }, }; - // Initialize active tab for the new workspace - const newActiveTabs = { - ...state.activeTab, - [newWorkspaceId]: "commands" as SidebarTab, - }; - // Initialize charts for the new workspace const newCharts = { ...state.charts, @@ -96,7 +89,6 @@ export const createWorkspacesSlice: StateCreator< activeWorkspace: newWorkspace, // Auto-switch to the new workspace workspaceFilters: newWorkspaceFilters, expandedItems: newExpandedItems, - activeTab: newActiveTabs, charts: newCharts, }; }); @@ -137,12 +129,10 @@ export const createWorkspacesSlice: StateCreator< // Clean up workspace-specific data const newWorkspaceFilters = { ...state.workspaceFilters }; const newExpandedItems = { ...state.expandedItems }; - const newActiveTabs = { ...state.activeTab }; const newCharts = { ...state.charts }; delete newWorkspaceFilters[id]; delete newExpandedItems[id]; - delete newActiveTabs[id]; delete newCharts[id]; return { @@ -150,7 +140,6 @@ export const createWorkspacesSlice: StateCreator< activeWorkspace: newActiveWorkspace, workspaceFilters: newWorkspaceFilters, expandedItems: newExpandedItems, - activeTab: newActiveTabs, charts: newCharts, }; }); diff --git a/frontend/testing-view/src/features/workspace/types/dndData.ts b/frontend/testing-view/src/features/workspace/types/dndData.ts index 3a13adf3d..7f9c8ca7e 100644 --- a/frontend/testing-view/src/features/workspace/types/dndData.ts +++ b/frontend/testing-view/src/features/workspace/types/dndData.ts @@ -6,6 +6,7 @@ type DndVariableData = { variableId: string; variableType: string; variableName: string; + variableEnumOptions?: string[]; }; type DndChartData = { diff --git a/frontend/testing-view/src/hooks/useAppConfigs.ts b/frontend/testing-view/src/hooks/useAppConfigs.ts index 4a8d29d98..07b885fda 100644 --- a/frontend/testing-view/src/hooks/useAppConfigs.ts +++ b/frontend/testing-view/src/hooks/useAppConfigs.ts @@ -1,9 +1,16 @@ import { useFetchConfig } from "@workspace/ui/hooks"; import { useEffect } from "react"; +import { useStore } from "../store/store"; import type { OrdersData, PacketsData } from "../types/data/board"; const useAppConfigs = (isConnected: boolean) => { - const backendUrl = import.meta.env.VITE_BACKEND_URL; + const backendUrl = + import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:4000/backend"; + const setConfig = useStore((s) => s.setConfig); + + useEffect(() => { + window.electronAPI?.getConfig().then(setConfig); + }, [setConfig]); const { data: packets, diff --git a/frontend/testing-view/src/hooks/useOpenFolder.ts b/frontend/testing-view/src/hooks/useOpenFolder.ts new file mode 100644 index 000000000..25c65b3f1 --- /dev/null +++ b/frontend/testing-view/src/hooks/useOpenFolder.ts @@ -0,0 +1,13 @@ +import { logger } from "@workspace/core"; + +export const useOpenFolder = () => { + const openFolder = (path?: string) => { + if (!window.electronAPI) { + logger.testingView.warn("electronAPI is not available"); + return; + } + window.electronAPI.openFolder(path ?? "."); + }; + + return { openFolder }; +}; diff --git a/frontend/testing-view/src/hooks/useTransformedBoards.ts b/frontend/testing-view/src/hooks/useTransformedBoards.ts index aa7cda62b..b1210c2cb 100644 --- a/frontend/testing-view/src/hooks/useTransformedBoards.ts +++ b/frontend/testing-view/src/hooks/useTransformedBoards.ts @@ -24,6 +24,11 @@ export function useTransformedBoards( ) return; + const store = useStore.getState(); + const wasAllCommands = store.isAllSelected("commands"); + const wasAllTelemetry = store.isAllSelected("telemetry"); + const wasAllLogs = store.isAllSelected("logs"); + setTelemetryCatalog(transformedBoards.telemetryCatalog); setCommandsCatalog(transformedBoards.commandsCatalog); setBoards(Array.from(transformedBoards.boards)); @@ -33,7 +38,12 @@ export function useTransformedBoards( const hasCommandsData = Object.keys(transformedBoards.commandsCatalog).length > 0; - if (hasTelemetryData && hasCommandsData) initializeWorkspaceFilters(); + if (hasTelemetryData && hasCommandsData) { + initializeWorkspaceFilters(); + if (wasAllCommands) useStore.getState().selectAllFilters("commands"); + if (wasAllTelemetry) useStore.getState().selectAllFilters("telemetry"); + if (wasAllLogs) useStore.getState().selectAllFilters("logs"); + } }, [ transformedBoards, setTelemetryCatalog, diff --git a/frontend/testing-view/src/lib/commandUtils.ts b/frontend/testing-view/src/lib/commandUtils.ts index 8b81ef888..e81161668 100644 --- a/frontend/testing-view/src/lib/commandUtils.ts +++ b/frontend/testing-view/src/lib/commandUtils.ts @@ -10,7 +10,7 @@ export const getDefaultParameterValues = ( Object.entries(fields).forEach(([key, field]) => { if (field.kind === "numeric") { - defaults[key] = 0; + defaults[key] = ""; } else if (field.kind === "enum") { defaults[key] = (field as EnumCommandParameter).options[0] || ""; } else if (field.kind === "boolean") { diff --git a/frontend/testing-view/src/lib/utils.test.ts b/frontend/testing-view/src/lib/utils.test.ts index fa667af7a..ed2000942 100644 --- a/frontend/testing-view/src/lib/utils.test.ts +++ b/frontend/testing-view/src/lib/utils.test.ts @@ -3,10 +3,12 @@ import { variablesBadgeClasses } from "../constants/variablesBadgeClasses"; import type { FilterScope } from "../features/filtering/types/filters"; import type { MessageTimestamp } from "../types/data/message"; import { + canAddSeriesToChart, createEmptyFilter, createFullFilter, formatName, formatTimestamp, + formatVariableValue, getCatalogKey, getTypeBadgeClass, } from "./utils"; @@ -103,6 +105,63 @@ describe("getTypeBadgeClass", () => { }); }); +describe("canAddSeriesToChart", () => { + it("should allow adding a numeric series to an empty chart", () => { + expect(canAddSeriesToChart([], false)).toBe(true); + }); + + it("should allow adding an enum series to an empty chart", () => { + expect(canAddSeriesToChart([], true)).toBe(true); + }); + + it("should prevent adding an enum series to a chart with existing series", () => { + expect(canAddSeriesToChart([{}], true)).toBe(false); + expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], true)).toBe(false); + }); + + it("should prevent adding a numeric series to a chart with an enum series", () => { + expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], false)).toBe(false); + }); + + it("should allow adding a numeric series to a chart with existing numeric series", () => { + expect(canAddSeriesToChart([{}], false)).toBe(true); + expect(canAddSeriesToChart([{}, {}], false)).toBe(true); + }); +}); + +describe("formatVariableValue", () => { + it("should return '—' for null or undefined", () => { + expect(formatVariableValue(null)).toBe("—"); + expect(formatVariableValue(undefined)).toBe("—"); + }); + + it("should return enum label by string value", () => { + expect(formatVariableValue("Running", ["Idle", "Running", "Fault"])).toBe("Running"); + }); + + it("should return enum label by numeric index", () => { + expect(formatVariableValue(1, ["Idle", "Running", "Fault"])).toBe("Running"); + }); + + it("should return raw string if index is out of bounds", () => { + expect(formatVariableValue(5, ["Idle", "Running"])).toBe("5"); + }); + + it("should format booleans as 0/1", () => { + expect(formatVariableValue(true)).toBe("1"); + expect(formatVariableValue(false)).toBe("0"); + }); + + it("should format numbers with 2 decimal places", () => { + expect(formatVariableValue(3.14159)).toBe("3.14"); + expect(formatVariableValue(42)).toBe("42.00"); + }); + + it("should format object with last/average using last", () => { + expect(formatVariableValue({ last: 1.5, average: 1.2 })).toBe("1.50"); + }); +}); + describe("emptyFilter", () => { it("should return the correct empty filter", () => { const boards = [ diff --git a/frontend/testing-view/src/lib/utils.ts b/frontend/testing-view/src/lib/utils.ts index 0df30e9c3..dc73e8ba9 100644 --- a/frontend/testing-view/src/lib/utils.ts +++ b/frontend/testing-view/src/lib/utils.ts @@ -1,3 +1,4 @@ +import type { VariableValue } from "@workspace/core"; import { ACRONYMS } from "../constants/acronyms"; import { variablesBadgeClasses } from "../constants/variablesBadgeClasses"; import type { @@ -120,6 +121,30 @@ export const formatTimestamp = (ts: MessageTimestamp) => { return `${ts.hour.toString().padStart(2, "0")}:${ts.minute.toString().padStart(2, "0")}:${ts.second.toString().padStart(2, "0")}`; }; +export const canAddSeriesToChart = ( + chartSeries: { enumOptions?: string[] }[], + incomingIsEnum: boolean, +): boolean => { + const chartHasEnum = chartSeries.some((s) => (s.enumOptions?.length ?? 0) > 0); + if (incomingIsEnum && chartSeries.length > 0) return false; + if (!incomingIsEnum && chartHasEnum) return false; + return true; +}; + +export const formatVariableValue = ( + m: VariableValue | null | undefined, + enumOptions?: string[], +): string => { + if (m == null) return "—"; + if (enumOptions?.length) { + return typeof m === "string" ? m : (enumOptions[m as number] ?? String(m)); + } + if (typeof m === "boolean") return m ? "1" : "0"; + if (typeof m === "object" && "last" in m) return m.last.toFixed(2); + if (typeof m === "number") return m.toFixed(2); + return String(m); +}; + export const detectExtraBoards = ( activeFilters: TabFilter | undefined, boards: BoardName[], diff --git a/frontend/testing-view/src/store/store.ts b/frontend/testing-view/src/store/store.ts index 26c3602f8..dfc7c58c8 100644 --- a/frontend/testing-view/src/store/store.ts +++ b/frontend/testing-view/src/store/store.ts @@ -58,6 +58,9 @@ export const useStore = create()( { // Partial persist name: "testing-view-storage", + onRehydrateStorage: () => () => { + document.documentElement.setAttribute("data-store-hydrated", "true"); + }, partialize: (state) => ({ // Charts charts: state.charts, @@ -72,7 +75,6 @@ export const useStore = create()( testingPage: state.testingPage, // Workspace UI state - activeTab: state.activeTab, workspaceFilters: state.workspaceFilters, }), }, diff --git a/frontend/testing-view/src/vite-end.d.ts b/frontend/testing-view/src/vite-end.d.ts index 040a51601..28748e194 100644 --- a/frontend/testing-view/src/vite-end.d.ts +++ b/frontend/testing-view/src/vite-end.d.ts @@ -7,6 +7,7 @@ interface ElectronAPI { getConfig: () => Promise; importConfig: () => Promise; selectFolder: () => Promise; + openFolder: (path: string) => Promise; } declare global { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72223697e..3eb9ad9ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: backend: {} + e2e: + devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.58.2 + electron: + specifier: ^40.1.0 + version: 40.1.0 + electron-app: dependencies: '@iarna/toml': @@ -833,6 +842,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3045,6 +3059,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4050,6 +4069,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -5642,6 +5671,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6696,7 +6729,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -6732,7 +6765,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/lodash@4.17.23': {} @@ -6766,7 +6799,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/through@0.0.33': dependencies: @@ -6779,7 +6812,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 optional: true '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': @@ -8265,6 +8298,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9308,6 +9344,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b2f6ce5b..f0086a994 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - "backend" - "electron-app" - "packet-sender" + - "e2e"