diff --git a/.gitignore b/.gitignore index 35e1d33..5be89ea 100644 --- a/.gitignore +++ b/.gitignore @@ -161,7 +161,7 @@ cython_debug/ #.idea/ # VSCode -.vscode +#.vscode # PyInstaller version file (dynamically built) file_version.txt @@ -169,3 +169,4 @@ file_version.txt # macOS build app directory (not required) dist/EX-Installer-macOS.app/ .DS_Store +src/bin diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..78606f5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,114 @@ +{ + "version": "0.2.0", + "configurations": [ + // ── Main process (Node.js) ──────────────────────────────────────────── + // Follows the official Electron docs pattern: "request": "launch" so + // VS Code owns the process — no preLaunchTask / background task needed. + // electron-vite dev --inspect=9229 builds, starts Vite servers, then + // spawns Electron with --inspect=9229 for the Node.js debugger to attach. + { + "name": "Main Process", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/src/node_modules/.bin/electron-vite", + "runtimeArgs": [ + "dev", + "--inspect=9229" + ], + "cwd": "${workspaceFolder}/src", + "outputCapture": "std", + "console": "integratedTerminal", + // Don't inject VS Code's own inspector into electron-vite. + // Instead, simply attach to the port that Electron opens via --inspect=9229. + "attachSimplePort": 9229, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/src/out/main/**/*.js", + "${workspaceFolder}/src/out/preload/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/src/**", + "!**/node_modules/**" + ], + "sourceMapPathOverrides": { + "../../main/*": "${workspaceFolder}/src/main/*", + "../../preload/*": "${workspaceFolder}/src/preload/*" + }, + "skipFiles": [ + "/**", + "${workspaceFolder}/src/node_modules/**" + ] + }, + { + "name": "Main Process (Mock)", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/src/node_modules/.bin/electron-vite", + "runtimeArgs": [ + "dev", + "--inspect=9229", + "--", + "--mock" + ], + "cwd": "${workspaceFolder}/src", + "outputCapture": "std", + "console": "integratedTerminal", + "attachSimplePort": 9229, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/src/out/main/**/*.js", + "${workspaceFolder}/src/out/preload/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/src/**", + "!**/node_modules/**" + ], + "sourceMapPathOverrides": { + "../../main/*": "${workspaceFolder}/src/main/*", + "../../preload/*": "${workspaceFolder}/src/preload/*" + }, + "skipFiles": [ + "/**", + "${workspaceFolder}/src/node_modules/**" + ] + }, + // ── Renderer process (Chromium) ─────────────────────────────────────── + // Start "Main Process" first, then attach this. + // Or use the "Debug All" compound to launch both together. + { + "name": "Renderer Process", + "type": "chrome", + "request": "attach", + "port": 9222, + "urlFilter": "http://localhost:*", + "webRoot": "${workspaceFolder}/src/renderer/src", + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/src/**", + "!**/node_modules/**" + ], + "skipFiles": [ + "${workspaceFolder}/src/node_modules/**" + ] + } + ], + // ── Compound: debug main + renderer together ────────────────────────────── + "compounds": [ + { + "name": "Debug All", + "configurations": [ + "Main Process", + "Renderer Process" + ], + "stopAll": true + }, + { + "name": "Debug All (Mock)", + "configurations": [ + "Main Process (Mock)", + "Renderer Process" + ], + "stopAll": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1fcd66e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "python-envs.pythonProjects": [], + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "cSpell.words": [ + "DBUS", + "fqbn", + "maximizable" + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8aef32a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,46 @@ +{ + "version": "2.0.0", + "tasks": [ + // ── Kill lingering Electron processes ───────────────────────────────── + { + "label": "kill-electron", + "type": "shell", + "command": "pkill -f electron || true", + "presentation": { + "reveal": "silent", + "panel": "shared" + }, + "problemMatcher": [] + }, + // ── Build ───────────────────────────────────────────────────────────── + { + "label": "electron-vite: build", + "type": "shell", + "command": "pnpm build", + "options": { + "cwd": "${workspaceFolder}/src" + }, + "group": "build", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": [] + }, + // ── Run (mock mode) ───────────────────────────────────────────────── + { + "label": "electron-vite: dev (mock)", + "type": "shell", + "command": "pnpm exec electron-vite dev -- --mock", + "options": { + "cwd": "${workspaceFolder}/src" + }, + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/dist/EX-Installer-Linux64 b/dist/EX-Installer-Linux64 deleted file mode 100755 index dd5365b..0000000 Binary files a/dist/EX-Installer-Linux64 and /dev/null differ diff --git a/dist/EX-Installer-Win32.exe b/dist/EX-Installer-Win32.exe deleted file mode 100644 index 638d298..0000000 Binary files a/dist/EX-Installer-Win32.exe and /dev/null differ diff --git a/dist/EX-Installer-Win64.exe b/dist/EX-Installer-Win64.exe deleted file mode 100644 index 8097272..0000000 Binary files a/dist/EX-Installer-Win64.exe and /dev/null differ diff --git a/dist/EX-Installer-macOS b/dist/EX-Installer-macOS deleted file mode 100755 index 14f6db7..0000000 Binary files a/dist/EX-Installer-macOS and /dev/null differ diff --git a/docs/conversion-plan.md b/docs/conversion-plan.md new file mode 100644 index 0000000..1801ac0 --- /dev/null +++ b/docs/conversion-plan.md @@ -0,0 +1,251 @@ +# EX-Installer: Python → Electron/Aurelia 2 Conversion Plan + +## Part 1 — How the Python `ex_installer` Works + +### Architecture Overview + +EX-Installer is a **CustomTkinter** desktop GUI that guides users through installing DCC-EX model railroad firmware (EX-CommandStation, EX-IOExpander, EX-Turntable) onto Arduino-compatible boards. It uses a **wizard-style, multi-view architecture** with a single root window (`EXInstaller`) that swaps child frames in and out via `switch_view()`. + +### Module Map + +``` +__main__.py Entry point — CLI args, logging, DPI scaling, mainloop +ex_installer.py Root window — view registry, menu bar, navigation controller +├── welcome.py Step 1: Welcome / landing page +├── manage_arduino_cli.py Step 2: Download/install/refresh Arduino CLI + platforms + libraries +├── select_device.py Step 3: Scan USB, select connected Arduino board +├── select_product.py Step 4: Choose product (CommandStation, IOExpander, Turntable) +├── select_version_config.py Step 5: Choose version (Prod/Devel/tag), clone repo, choose config source +├── ex_commandstation.py Step 6a: CommandStation config (motor driver, WiFi, TrackManager, etc.) +├── ex_ioexpander.py Step 6b: IOExpander config (I2C address, diagnostics) +├── ex_turntable.py Step 6c: Turntable config (stepper, sensors, phase switching) +├── advanced_config.py Step 7 (optional): Direct text editing of generated config files +├── compile_upload.py Step 8: Compile sketch + upload to board via Arduino CLI +├── serial_monitor.py Utility: Live serial port monitor with color highlighting +├── arduino_cli.py Service: Arduino CLI wrapper (download, install, compile, upload) +├── git_client.py Service: pygit2 wrapper (clone, pull, list versions) +├── file_manager.py Service: File I/O, downloads, archive extraction, user preferences +├── common_widgets.py UI: Base view layout (WindowLayout), navigation bar (NextBack), tooltip, textbox +├── common_fonts.py UI: Shared font definitions +├── product_details.py Data: Product metadata (repos, supported devices, config files) +└── version.py Data: App version string +``` + +### Core Patterns + +#### 1. View Navigation +`EXInstaller` maintains a `views` dict mapping string keys to view classes and a `frames` dict caching instantiated views. `switch_view(view_class, product, version)` either raises an existing frame or creates a new one. Some views (compile_upload, advanced_config) are always re-created to reset state. + +#### 2. Layout +Every view inherits from `WindowLayout(ctk.CTkFrame)`, which provides: +- **Title frame** (top) — product logo + title text +- **Main frame** (center) — view-specific content +- **Status frame** (bottom) — `NextBack` navigation bar with Back/Next/Log/Monitor buttons + +`WindowLayout` also provides properties that reach up to the root `EXInstaller` to access shared singletons: `self.acli` (ArduinoCLI), `self.git` (GitClient), `self.common_fonts`, `self.app_version`. + +#### 3. Async Processing +Long-running operations (download, extract, git clone, Arduino CLI compile/upload) run in **daemon threads** (`ThreadedArduinoCLI`, `ThreadedGitClient`, `ThreadedDownloader`, `ThreadedExtractor`). Each thread posts `QueueMessage(status, topic, data)` namedtuples to a `queue.Queue`. The view polls with `self.after(100, self.process_monitor)`, reads from the queue, and generates **custom tkinter events** (`<>`) that trigger state-machine handler methods dispatched by `process_phase` / `process_status`. + +#### 4. State Machines +`manage_arduino_cli`, `select_version_config`, and `compile_upload` use `match/case` blocks on `(process_phase, process_status)` to sequence multi-step workflows. For example, `manage_arduino_cli`'s install flow: `install_cli` → `download` → `extract` → `init` → `update_index` → `install_packages` → `install_libraries` → `refresh_boards`. + +#### 5. Config Generation +Product views (`ex_commandstation`, `ex_ioexpander`, `ex_turntable`) collect UI form values, validate them, and produce lists of C `#define` lines written to files like `config.h`, `myConfig.h`, and `myAutomation.h` in the cloned repo directory via `FileManager.write_config_file()`. + +#### 6. External Tools +- **Arduino CLI** (v0.35.3 binary) — downloaded per-platform, invoked via subprocess for board detection, platform/library installation, sketch compilation, and firmware upload. +- **pygit2** — clones DCC-EX repos, pulls updates, lists tags/branches for version selection. +- **pyserial** — serial port enumeration and communication for the serial monitor. +- **requests** — HTTP downloads for Arduino CLI binary. + +### Data Flow Summary + +``` +USB Board Detection: + Arduino CLI `board list` → JSON → SelectDevice radio buttons → acli.selected_device + +Version Selection: + pygit2 clone/pull → tag list → SelectVersionConfig combos → checkout tag → source tree on disk + +Config Generation: + Product form values → validation → #define lines → write to /config.h + +Compile & Upload: + Arduino CLI compile(sketch_dir, fqbn) → Arduino CLI upload(hex, port) → success/error +``` + +--- + +## Part 2 — Conversion Plan to Electron / Aurelia 2 + +### What Already Exists in the Electron App + +The Electron shell is functional with: +- **Main process**: `BrowserWindow`, IPC handler registration, `PythonRunner` (python-shell wrapper), `UsbManager` (serial port + USB hotplug via `serialport` + `usb` packages) +- **Preload**: `contextBridge` exposes `window.python` and `window.usb` APIs +- **Renderer**: Aurelia 2 app with `PythonService`, `UsbService`, a `DeviceList` component, and Syncfusion UI library (buttons, base styles, Tailwind CSS v4) +- **IPC types**: Shared TypeScript interfaces for serial/USB/Python contracts + +### Conversion Strategy + +The conversion replaces the **Python/CustomTkinter GUI layer** while preserving (and eventually replacing) the Python **service layer** underneath. The approach is: + +1. **Views → Aurelia 2 components** (HTML templates + TypeScript view-models) +2. **WindowLayout base → Aurelia 2 layout component** with slot-based composition +3. **Threading + Queues → Electron IPC + async/await** (already partially built) +4. **Arduino CLI wrapper → main-process service** exposed via IPC +5. **Git operations → main-process service** (isomorphic-git or shell git) exposed via IPC +6. **File management → main-process service** using Node.js `fs` via IPC +7. **Serial monitor → renderer component** backed by existing `UsbService` +8. **Config generation → pure TypeScript functions** (no file I/O needed until write) + +### Phase Breakdown + +#### Phase 1 — Core Infrastructure (Foundation) + +| Task | Details | +|------|---------| +| **Router + navigation** | Install `@aurelia/router`. Define route graph matching Python view flow: `welcome` → `manage-cli` → `select-device` → `select-product` → `select-version` → `[product-config]` → `advanced-config?` → `compile-upload`. | +| **Shell layout component** | Create `` with ``, `` (main), ``. Replaces `WindowLayout`. | +| **Navigation bar component** | `` with back/next buttons, bindable `back-command`, `next-command`, `back-text`, `next-text`, `show-monitor` etc. Replaces `NextBack`. | +| **Shared state store** | Aurelia 2 DI singleton `InstallerState` holding: `selectedDevice`, `selectedProduct`, `selectedVersion`, `preferences`, `appVersion`. Replaces class-level variables on `EXInstaller`. | +| **Preferences service** | Main-process `electron-store` (or JSON file via IPC) replacing `FileManager.get_user_preferences()`. | + +#### Phase 2 — Main-Process Services (Backend) + +| Task | Details | +|------|---------| +| **ArduinoCLI service** | New `src/main/arduino-cli.ts`: download binary per-platform, spawn `arduino-cli` subprocess (JSON mode), parse output. Methods: `isInstalled()`, `getVersion()`, `downloadCli()`, `installCli()`, `deleteCLI()`, `initConfig()`, `updateIndex()`, `installPlatform()`, `installLibrary()`, `getPlatforms()`, `getLibraries()`, `listBoards()`, `compile()`, `upload()`. Expose all via IPC handlers in `src/main/ipc/arduino-cli-ipc.ts`. | +| **Git service** | New `src/main/git-client.ts`: use `simple-git` (or shell `git`). Methods: `clone()`, `pull()`, `listTags()`, `checkout()`, `checkLocalChanges()`, `hardReset()`. Expose via IPC. | +| **File service** | New `src/main/file-manager.ts`: wraps Node.js `fs/promises`. Methods: `readFile()`, `writeFile()`, `listDir()`, `copyFiles()`, `deleteFiles()`, `downloadFile()`, `extractArchive()`, `getInstallDir()`. Expose via IPC. | +| **Preload expansion** | Add `window.arduinoCli`, `window.git`, `window.files` API surfaces via `contextBridge`. | +| **IPC type contracts** | Extend `src/types/ipc.ts` with interfaces for all new APIs. | + +#### Phase 3 — Renderer Views (UI Step-by-Step) + +Each Python view becomes an Aurelia 2 routed component. Build them in wizard order: + +| # | Python View | Aurelia Component | Key UI Elements (Syncfusion / Tailwind) | +|---|-------------|-------------------|----------------------------------------| +| 1 | `welcome.py` | `welcome-view` | Bullet-point intro text, product logos. Next only. | +| 2 | `manage_arduino_cli.py` | `manage-cli-view` | Status labels, Install/Refresh button, progress indicator, ESP32/STM32 platform toggle switches. State-machine progress via async generators or observable status. | +| 3 | `select_device.py` | `select-device-view` | Scan button, radio-button list of detected devices (from `window.arduinoCli.listBoards()` + `window.usb.listSerialPorts()`), combo box for ambiguous boards. | +| 4 | `select_product.py` | `select-product-view` | Product cards with logos: EX-CommandStation, EX-IOExpander, EX-Turntable. Device compatibility check gating. | +| 5 | `select_version_config.py` | `select-version-view` | Version radio buttons (Latest Prod / Latest Devel / Select), version dropdown, config source radio (New / Existing / Load). Git clone/pull progress. | +| 6a | `ex_commandstation.py` | `commandstation-config-view` | Tabbed form (General / WiFi / TrackManager). Motor driver combo, display radios, WiFi AP/STA config, track mode combos, current limit, EEPROM toggle. | +| 6b | `ex_ioexpander.py` | `ioexpander-config-view` | I2C address stepper, pullup toggle, diagnostic switches. | +| 6c | `ex_turntable.py` | `turntable-config-view` | Tabbed form (General / Stepper / Advanced). I2C address, mode toggle, stepper driver combo, sensor toggles, phase switching angle, acceleration/speed entries. | +| 7 | `advanced_config.py` | `advanced-config-view` | Code editor textboxes (or Monaco/CodeMirror lite) for generated config files. | +| 8 | `compile_upload.py` | `compile-upload-view` | Compile button, progress bar, log output textbox, backup config popup. | +| — | `serial_monitor.py` | `serial-monitor` | Panel/modal with output textbox, command input, color-highlighted log, save button. Wired to existing `UsbService`. | + +#### Phase 4 — Config Generation (Business Logic) + +| Task | Details | +|------|---------| +| **Product details** | Port `product_details.py` → `src/renderer/src/models/product-details.ts` as typed constants. | +| **Config generators** | Port `generate_config()` / `generate_myAutomation()` from each product view into pure TypeScript functions: `generate-commandstation-config.ts`, `generate-ioexpander-config.ts`, `generate-turntable-config.ts`. Each takes a typed options object, validates, returns `{ valid: boolean, files: { name: string, content: string }[] }`. | +| **Motor driver parser** | Port `get_motor_drivers()` logic — read `MotorDrivers.h` from cloned repo (via file IPC), regex-extract driver names. | +| **Stepper parser** | Port `get_steppers()` — read `standard_steppers.h`, extract stepper definitions. | + +#### Phase 5 — Polish & Feature Parity + +| Task | Details | +|------|---------| +| **Error handling** | Global error boundary component. Electron `dialog.showErrorBox()` for critical failures. Log file access via IPC. | +| **Menu bar** | Electron native menu: Info (About, Website, Instructions, News, Debug toggle), Tools (Scaling via `webFrame.setZoomFactor()`). | +| **Logging** | `electron-log` in main process. Renderer console forwarded to main log file. | +| **Fake device mode** | CLI flag / env var `--fake` that makes `ArduinoCLIService` return mock data. | +| **User preferences** | Screen scaling, remembered settings via `electron-store`. | +| **Packaging** | `electron-builder` config (already partially done in `package.json`). Bundle Arduino CLI binary + Python scripts as `extraResources`. | + +### Suggested File Structure (Final) + +``` +src/ + main/ + index.ts # App lifecycle, window creation + config.ts # Static app config + python-runner.ts # Python subprocess wrapper (existing) + usb-manager.ts # Serial/USB manager (existing) + arduino-cli.ts # NEW — Arduino CLI subprocess wrapper + git-client.ts # NEW — Git operations (simple-git) + file-manager.ts # NEW — File I/O operations + ipc/ + index.ts # Register all IPC handlers + python-ipc.ts # (existing) + usb-ipc.ts # (existing) + arduino-cli-ipc.ts # NEW + git-ipc.ts # NEW + file-ipc.ts # NEW + preload/ + index.ts # contextBridge (extend with new APIs) + types/ + ipc.ts # Shared type contracts (extend) + renderer/ + index.html + src/ + main.ts # Aurelia bootstrap + app.ts # Root component (router host) + app.html # Router outlet + shell layout + styles.css # Tailwind + Syncfusion imports + models/ + product-details.ts # Product metadata constants + installer-state.ts # Shared state singleton (DI) + services/ + python.service.ts # (existing) + usb.service.ts # (existing) + arduino-cli.service.ts # NEW — wraps window.arduinoCli + git.service.ts # NEW — wraps window.git + file.service.ts # NEW — wraps window.files + preferences.service.ts # NEW — wraps window.preferences + config/ + commandstation.ts # Config generator (pure logic) + ioexpander.ts # Config generator + turntable.ts # Config generator + motor-drivers.ts # MotorDrivers.h parser + steppers.ts # standard_steppers.h parser + components/ + app-layout.html # Shell layout (title / main / status slots) + app-layout.ts + nav-bar.html # Back/Next navigation bar + nav-bar.ts + device-list.html # (existing) + device-list.ts # (existing) + serial-monitor.html # Serial monitor panel + serial-monitor.ts + views/ + welcome.html + .ts + manage-cli.html + .ts + select-device.html + .ts + select-product.html + .ts + select-version.html + .ts + commandstation-config.html + .ts + ioexpander-config.html + .ts + turntable-config.html + .ts + advanced-config.html + .ts + compile-upload.html + .ts +``` + +### Migration Priority Order + +1. **Phase 1** (routing, layout, state) — unblocks all view work +2. **Phase 2** (Arduino CLI + Git services) — unblocks the real workflows +3. **Phase 3** views 1–3 (welcome, manage CLI, select device) — first end-to-end flow +4. **Phase 3** views 4–5 (select product, select version) — complete pre-config wizard +5. **Phase 4** (config generators) + **Phase 3** views 6a–6c — product configuration +6. **Phase 3** views 7–8 (advanced config, compile/upload) — complete the wizard +7. **Phase 5** (polish, menus, logging, packaging) — production readiness + +### Key Design Decisions + +| Decision | Recommendation | Rationale | +|----------|---------------|-----------| +| **Routing library** | `@aurelia/router` | Native Aurelia 2 router with lifecycle hooks matching the wizard flow. | +| **State management** | Aurelia DI singleton | Simple, no extra library. A single `InstallerState` class registered as a singleton covers all shared state. | +| **Arduino CLI invocation** | Node.js `child_process.spawn` in main process | Direct subprocess control, JSON output parsing, progress streaming via IPC. No Python dependency for this. | +| **Git operations** | `simple-git` npm package in main | Pure JS, no native deps, well-maintained. Replaces pygit2. | +| **Config file editing** | CodeMirror 6 or plain ` + + +
+ No configuration files loaded. +
+ + + + + +
+ +
+
+ ✓ + Success + ✗ Failed + Compiling... + Output +
+ +
+ +
+
${compileError}
+
${compileLog}
+
+
+ + + + +
+ \ No newline at end of file diff --git a/src/renderer/src/views/workspace.ts b/src/renderer/src/views/workspace.ts new file mode 100644 index 0000000..1d9ec45 --- /dev/null +++ b/src/renderer/src/views/workspace.ts @@ -0,0 +1,268 @@ +import { resolve } from 'aurelia' +import { Router } from '@aurelia/router' +import { IDialogService } from '@aurelia/dialog' +import { InstallerState } from '../models/installer-state' +import { PreferencesService } from '../services/preferences.service' +import { FileService } from '../services/file.service' +import { ArduinoCliService } from '../services/arduino-cli.service' +import { ConfigService } from '../services/config.service' +import { DeviceWizard } from '../components/device-wizard' +import { productDetails } from '../models/product-details' +import type { SavedConfiguration } from '../models/saved-configuration' + +export class Workspace { + private readonly router = resolve(Router) + private readonly dialogService = resolve(IDialogService) + readonly state = resolve(InstallerState) + private readonly preferences = resolve(PreferencesService) + private readonly files = resolve(FileService) + private readonly cli = resolve(ArduinoCliService) + private readonly config = resolve(ConfigService) + + // ── Active config file being edited ───────────────────────────────────── + activeFileIndex = 0 + + isMock = false + + // ── Compile / upload state ─────────────────────────────────────────────── + isCompiling = false + compileLog = '' + compileSuccess: boolean | null = null + compileError: string | null = null + progressPercent = 0 + + // ── Menu ───────────────────────────────────────────────────────────────── + showDeviceMenu = false + savedConfigs: SavedConfiguration[] = [] + + async binding(): Promise { + await this.config.ready + this.isMock = this.config.isMock + if (!this.state.selectedDevice) { + await this.router.load('home') + return + } + await this.loadSavedConfigs() + await this.refreshConfigFilesFromDisk() + } + + /** + * Re-reads each config file from disk, replacing any empty content. + * This heals stale saved configs and handles the first-ever load after + * the mock repo is seeded. + */ + private async refreshConfigFilesFromDisk(): Promise { + if (!this.state.scratchPath) return + let changed = false + for (const f of this.state.configFiles) { + if (f.content.trim() !== '') continue // already has content + const diskPath = `${this.state.scratchPath}/${f.name}` + const diskExists = await this.files.exists(diskPath) + if (diskExists) { + f.content = await this.files.readFile(diskPath) + changed = true + } + } + if (changed) await this.updateSavedConfig() + } + + private async loadSavedConfigs(): Promise { + try { + const saved = await this.preferences.get('savedConfigurations') as SavedConfiguration[] | undefined + this.savedConfigs = Array.isArray(saved) ? saved : this.state.savedConfigurations + } catch { + this.savedConfigs = this.state.savedConfigurations + } + } + + // ── Config file editing ─────────────────────────────────────────────────── + get activeFile(): { name: string; content: string } | null { + return this.state.configFiles[this.activeFileIndex] ?? null + } + + setActiveFile(index: number): void { + this.activeFile && this.syncContent() + this.activeFileIndex = index + } + + syncContent(): void { + // content is already two-way bound via textarea; nothing extra needed + } + + async saveFiles(): Promise { + for (const f of this.state.configFiles) { + if (this.state.scratchPath) { + await this.files.writeFile(`${this.state.scratchPath}/${f.name}`, f.content) + } + } + await this.updateSavedConfig() + } + + private async updateSavedConfig(): Promise { + const id = this.state.activeConfigId + if (!id) return + const idx = this.state.savedConfigurations.findIndex((c) => c.id === id) + if (idx === -1) return + this.state.savedConfigurations[idx] = { + ...this.state.savedConfigurations[idx], + configFiles: this.state.configFiles.map((f) => ({ ...f })), + lastModified: new Date().toISOString(), + } + await this.preferences.set('savedConfigurations', this.state.savedConfigurations) + } + + // ── Compile & Upload ────────────────────────────────────────────────────── + clearCompileLog(): void { + this.compileLog = '' + this.compileSuccess = null + this.compileError = null + } + + async compile(): Promise { + const device = this.state.selectedDevice + if (!device || !this.state.scratchPath) return + + this.isCompiling = true + this.compileLog = '' + this.compileError = null + this.compileSuccess = null + this.progressPercent = 10 + + try { + await this.saveFiles() + this.progressPercent = 20 + + const fqbn = device.fqbn + if (!fqbn) { + throw new Error(`Board "${device.name}" has no FQBN — install Arduino CLI and rescan to identify it.`) + } + + this.compileLog += `Compiling for ${fqbn}...\n` + this.progressPercent = 40 + const result = await this.cli.compile(this.state.scratchPath!, fqbn) + this.compileLog += result.output ?? '' + if (!result.success) throw new Error(result.error ?? 'Compilation failed') + + this.progressPercent = 70 + this.compileSuccess = true + this.compileLog += '\n✓ Compile successful!' + } catch (err) { + this.compileError = (err as Error).message + this.compileSuccess = false + } finally { + this.isCompiling = false + } + } + + async upload(): Promise { + const device = this.state.selectedDevice + if (!device || !this.state.scratchPath) return + + this.isCompiling = true + this.compileError = null + this.progressPercent = 75 + + try { + const fqbn = device.fqbn + if (!fqbn) { + throw new Error(`Board "${device.name}" has no FQBN — install Arduino CLI and rescan to identify it.`) + } + + this.compileLog += `\nUploading to ${device.port}...\n` + this.progressPercent = 80 + const result = await this.cli.upload(this.state.scratchPath!, fqbn, device.port) + this.compileLog += result.output ?? '' + if (!result.success) throw new Error(result.error ?? 'Upload failed') + + this.progressPercent = 100 + this.compileSuccess = true + this.compileLog += '\n✓ Upload complete!' + } catch (err) { + this.compileError = (err as Error).message + this.compileSuccess = false + } finally { + this.isCompiling = false + } + } + + async compileAndUpload(): Promise { + await this.compile() + if (this.compileSuccess) { + await this.upload() + } + } + + // ── Device switching ────────────────────────────────────────────────────── + toggleDeviceMenu(): void { + this.showDeviceMenu = !this.showDeviceMenu + } + + async switchToConfig(config: SavedConfiguration): Promise { + this.showDeviceMenu = false + this.state.selectedDevice = { name: config.deviceName, port: config.devicePort, fqbn: config.deviceFqbn, protocol: 'serial' } + this.state.selectedProduct = config.product + this.state.selectedVersion = config.version + this.state.repoPath = config.repoPath + this.state.scratchPath = config.scratchPath + this.state.configFiles = config.configFiles.map((f) => ({ ...f })) + this.state.activeConfigId = config.id + this.activeFileIndex = 0 + this.compileLog = '' + this.compileSuccess = null + await this.refreshConfigFilesFromDisk() + } + + async addNewDevice(): Promise { + this.showDeviceMenu = false + this.state.reset() + const result = await this.dialogService + .open({ component: () => DeviceWizard }) + .whenClosed((r) => r) + if (typeof result === 'object' && result !== null && 'status' in result && (result as any).status === 'ok') { + await this.loadSavedConfigs() + // The wizard stored the new config in state directly and in savedConfigurations. + // Re-apply it via switchToConfig so configFiles / repoPath are all in sync. + const newId = ((result as any).value as { id: string } | undefined)?.id + const newConfig = this.state.savedConfigurations.find((c) => c.id === newId) + if (newConfig) { + await this.switchToConfig(newConfig) + } + } + } + + async deleteConfig(config: SavedConfiguration, event: Event): Promise { + event.stopPropagation() + this.state.savedConfigurations = this.state.savedConfigurations.filter((c) => c.id !== config.id) + this.savedConfigs = this.savedConfigs.filter((c) => c.id !== config.id) + await this.preferences.set('savedConfigurations', this.state.savedConfigurations) + // If the deleted config was the one open, switch to the next or go home + if (config.id === this.state.activeConfigId) { + const next = this.savedConfigs[0] + if (next) { + await this.switchToConfig(next) + } else { + this.showDeviceMenu = false + this.router.load('home') + } + } + } + + goHome(): void { + this.router.load('home') + } + + get productName(): string { + const key = this.state.selectedProduct + return key ? (productDetails[key]?.productName ?? key) : '' + } + + get activeConfigName(): string { + const id = this.state.activeConfigId + if (!id) return this.state.selectedDevice?.name ?? '' + return ( + this.savedConfigs.find((c) => c.id === id)?.name ?? + this.state.savedConfigurations.find((c) => c.id === id)?.name ?? + this.state.selectedDevice?.name ?? '' + ) + } +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..a256c92 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.web.json" + } + ] +} \ No newline at end of file diff --git a/src/tsconfig.node.json b/src/tsconfig.node.json new file mode 100644 index 0000000..37694a4 --- /dev/null +++ b/src/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "include": [ + "electron.vite.config.*", + "main/**/*", + "preload/**/*", + "types/**/*" + ], + "compilerOptions": { + "composite": true, + "types": ["node"], + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/src/tsconfig.web.json b/src/tsconfig.web.json new file mode 100644 index 0000000..f02709b --- /dev/null +++ b/src/tsconfig.web.json @@ -0,0 +1,24 @@ +{ + "include": [ + "renderer/src/**/*", + "renderer/src/**/*.html", + "types/**/*" + ], + "compilerOptions": { + "composite": true, + "types": [], + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "useDefineForClassFields": false, + "resolveJsonModule": true + } +} \ No newline at end of file diff --git a/src/types/ipc.ts b/src/types/ipc.ts new file mode 100644 index 0000000..2dd6b7b --- /dev/null +++ b/src/types/ipc.ts @@ -0,0 +1,191 @@ +/** + * Shared IPC type contracts — imported by main, preload, and renderer. + * Keep this file free of Node.js / browser-only dependencies so it is + * safe to import from every process. + */ + +// ── USB / Serial ───────────────────────────────────────────────────────────── + +export interface SerialDeviceInfo { + path: string + manufacturer?: string + serialNumber?: string + vendorId?: string + productId?: string +} + +export interface UsbDeviceInfo { + vendorId: number + productId: number + deviceAddress: number + busNumber: number + deviceDescriptor: { + idVendor: number + idProduct: number + iManufacturer: number + iProduct: number + iSerialNumber: number + } +} + +// ── Python ─────────────────────────────────────────────────────────────────── + +export interface PythonJobOptions { + script: string + args?: string[] + cwd?: string + env?: Record + mode?: 'text' | 'json' | 'binary' +} + +export interface PythonJobResult { + jobId: string + exitCode: number | null + output: string[] + error?: string +} + +// ── Window-level API surfaces exposed through contextBridge ────────────────── + +export interface UsbElectronApi { + listSerialPorts: () => Promise + listUsbDevices: () => Promise + openPort: (path: string, baudRate?: number) => Promise + writeToPort: (path: string, data: string) => Promise + closePort: (path: string) => Promise + onData: (cb: (payload: { path: string; data: string }) => void) => () => void + onError: (cb: (payload: { path: string; message: string }) => void) => () => void + onClosed: (cb: (payload: { path: string }) => void) => () => void + onAttached: (cb: (payload: { vendorId: number; productId: number }) => void) => () => void + onDetached: (cb: (payload: { vendorId: number; productId: number }) => void) => () => void +} + +export interface PythonElectronApi { + run: (options: PythonJobOptions) => Promise + send: (jobId: string, message: string) => Promise + kill: (jobId: string) => Promise + onStdout: (cb: (payload: { jobId: string; line: string }) => void) => () => void + onStderr: (cb: (payload: { jobId: string; line: string }) => void) => () => void + onDone: (cb: (result: PythonJobResult) => void) => () => void + onError: (cb: (payload: { jobId: string; message: string }) => void) => () => void +} + +// ── Augment Window global ───────────────────────────────────────────────────── + +// ── Arduino CLI ────────────────────────────────────────────────────────────── + +export interface ArduinoCliPlatformInfo { + id: string + installed: string + latest: string + name: string +} + +export interface ArduinoCliLibraryInfo { + name: string + installedVersion: string + availableVersion?: string +} + +export interface ArduinoCliBoardInfo { + name: string + fqbn: string + port: string + protocol: string + serialNumber?: string +} + +export interface CompileResult { + success: boolean + output: string + error?: string +} + +export interface UploadResult { + success: boolean + output: string + error?: string +} + +export interface ArduinoCliElectronApi { + isInstalled: () => Promise + getVersion: () => Promise + downloadCli: () => Promise<{ success: boolean; error?: string }> + installPlatform: (platform: string, version?: string) => Promise<{ success: boolean; error?: string }> + installLibrary: (library: string, version?: string) => Promise<{ success: boolean; error?: string }> + getPlatforms: () => Promise + getLibraries: () => Promise + listBoards: () => Promise + compile: (sketchPath: string, fqbn: string) => Promise + upload: (sketchPath: string, fqbn: string, port: string) => Promise + initConfig: () => Promise<{ success: boolean; error?: string }> + updateIndex: () => Promise<{ success: boolean; error?: string }> + getBundledVersion: () => Promise + browseBinary: () => Promise + browsePlatformArchive: () => Promise + validateBinary: (binaryPath: string) => Promise<{ success: boolean; version?: string; error?: string }> + setCustomPath: (binaryPath: string) => Promise<{ success: boolean }> + installFromArchive: (archivePath: string) => Promise<{ success: boolean; error?: string }> + checkPlatform: (platformId: string) => Promise<{ installed: boolean; version: string | null }> + installPlatformFromArchive: (archivePath: string, platformId: string, version: string) => Promise<{ success: boolean; error?: string }> + onProgress: (cb: (payload: { phase: string; message: string }) => void) => () => void +} + +// ── Git ────────────────────────────────────────────────────────────────────── + +export interface GitVersionInfo { + tag: string + major: number + minor: number + patch: number + type: 'Prod' | 'Devel' | 'unknown' +} + +export interface GitElectronApi { + clone: (url: string, dest: string, branch?: string) => Promise<{ success: boolean; error?: string }> + pull: (repoPath: string) => Promise<{ success: boolean; error?: string }> + listTags: (repoPath: string) => Promise + checkout: (repoPath: string, ref: string) => Promise<{ success: boolean; error?: string }> + checkLocalChanges: (repoPath: string) => Promise<{ hasChanges: boolean; files: string[] }> + hardReset: (repoPath: string) => Promise<{ success: boolean; error?: string }> +} + +// ── File System ────────────────────────────────────────────────────────────── + +export interface FileElectronApi { + readFile: (filePath: string) => Promise + writeFile: (filePath: string, content: string) => Promise + listDir: (dirPath: string) => Promise + exists: (filePath: string) => Promise + mkdir: (dirPath: string) => Promise + copyFiles: (src: string, dest: string) => Promise + deleteFiles: (filePath: string) => Promise + getInstallDir: (subdir?: string) => Promise + selectDirectory: () => Promise +} + +// ── Preferences ────────────────────────────────────────────────────────────── + +export interface PreferencesElectronApi { + get: (key: string) => Promise + set: (key: string, value: unknown) => Promise + getAll: () => Promise> +} + +// ── Config ─────────────────────────────────────────────────────────────────── + +export interface ConfigElectronApi { + getMock: () => Promise +} + +declare global { + interface Window { + usb: UsbElectronApi + python: PythonElectronApi + arduinoCli: ArduinoCliElectronApi + git: GitElectronApi + files: FileElectronApi + preferences: PreferencesElectronApi + config: ConfigElectronApi + } +} diff --git a/src/types/starter-templates.ts b/src/types/starter-templates.ts new file mode 100644 index 0000000..9c44e9a --- /dev/null +++ b/src/types/starter-templates.ts @@ -0,0 +1,166 @@ +/** + * Starter config file templates — shipped with EX-Installer and used as a fallback + * when no config file exists on disk and no example file is found in the cloned repo. + * + * Each key is the repo folder name (matching the product's repoName after the '/'). + * Inner keys are the config file names (matching minimumConfigFiles / otherConfigFilePatterns). + * + * These are intentionally minimal / "clean" generated configs rather than the large + * commented example files found in the repos — they are ready-to-compile defaults that + * can be further edited by the user. + * + * Keep this file free of Node.js / browser-only dependencies so it is safe to import + * from every process (main, preload, renderer). + */ + +export const STARTER_TEMPLATES: Record> = { + /** + * EX-CommandStation — minimal generated config suitable for an EX-CSB1 + * (ESP32-S3, EXCSB1 motor driver, built-in SH1106 OLED, WiFi AP mode). + * + * Matches the output of generateCommandStationConfig() with these defaults: + * motorDriver: 'EXCSB1' + * display: 'OLED_132x64' (SH1106 onboard) + * enableWifi: true (auto for ESP32) + * wifiMode: 'ap' + * wifiHostname: 'dccex' + * wifiChannel: 1 + * disableEeprom: true (auto for ESP32) + * + * NOTE: IP_PORT, SCROLLMODE, WIFI_HOSTNAME, and OLED_DRIVER are always required — + * omitting any of them from a hand-edited config.h will cause a compile error. + */ + 'CommandStation-EX': { + 'config.h': `// config.h - Generated by EX-Installer + +#define IP_PORT 2560 +#define SCROLLMODE 1 + +#define MOTOR_SHIELD_TYPE EXCSB1 +#define OLED_DRIVER 132,64 +#define WIFI_HOSTNAME "dccex" +#define WIFI_SSID "Your network name" +#define WIFI_PASSWORD "Your network passwd" +#define ENABLE_WIFI true +#define WIFI_CHANNEL 1 +#define DISABLE_EEPROM +`, + }, + + /** + * EX-IOExpander — mirrors myConfig.example.h from the DCC-EX repo. + */ + 'EX-IOExpander': { + 'myConfig.h': `/* + * © 2022 Peter Cole. All rights reserved. + * + * This is the example configuration file for EX-IOExpander. + * + * It is highly recommended to copy this to "myConfig.h" and modify to suit your + * specific requirements. + * + * NOTE: Modifications to this file will be overwritten by future software updates. + */ +#ifndef MYCONFIG_H +#define MYCONFIG_H + +///////////////////////////////////////////////////////////////////////////////////// +// Define I2C address +// Default 0x65, can be any valid, available I2C address +// +#define I2C_ADDRESS 0x65 + +///////////////////////////////////////////////////////////////////////////////////// +// Uncomment to enable diag output +// +// #define DIAG + +///////////////////////////////////////////////////////////////////////////////////// +// Delay between dumping the status of the port config if DIAG enabled +// +#define DIAG_CONFIG_DELAY 5 + +///////////////////////////////////////////////////////////////////////////////////// +// Enable test mode - ensure only one test mode is active at one time. +// +// #define TEST_MODE ANALOGUE_TEST +// #define TEST_MODE INPUT_TEST +// #define TEST_MODE OUTPUT_TEST +// #define TEST_MODE PULLUP_TEST + +///////////////////////////////////////////////////////////////////////////////////// +// Uncomment to disable internal I2C pullup resistors +// +// #define DISABLE_I2C_PULLUPS + +#endif +`, + }, + + /** + * EX-Turntable — mirrors config.example.h from the DCC-EX repo. + */ + 'EX-Turntable': { + 'config.h': `/* + * © 2022 Peter Cole + * + * This is the configuration file for Turntable-EX. + */ + +///////////////////////////////////////////////////////////////////////////////////// +// Define a valid (and free) I2C address, 0x60 is the default. +// +#define I2C_ADDRESS 0x60 + +///////////////////////////////////////////////////////////////////////////////////// +// Define the mode for Turntable-EX. +// TURNTABLE : 360 degree rotation turntable (default). +// TRAVERSER : Vertical or horizontal traversers, or limited-rotation turntables. +// +#define TURNTABLE_EX_MODE TURNTABLE +// #define TURNTABLE_EX_MODE TRAVERSER + +///////////////////////////////////////////////////////////////////////////////////// +// Sensor active states. +// +#define HOME_SENSOR_ACTIVE_STATE LOW +#define LIMIT_SENSOR_ACTIVE_STATE LOW +#define RELAY_ACTIVE_STATE HIGH + +///////////////////////////////////////////////////////////////////////////////////// +// Phase switching. +// +#define PHASE_SWITCHING AUTO +#define PHASE_SWITCH_ANGLE 45 + +///////////////////////////////////////////////////////////////////////////////////// +// Stepper driver selection. +// +#define STEPPER_DRIVER ULN2003_HALF_CW +// #define STEPPER_DRIVER A4988 + +///////////////////////////////////////////////////////////////////////////////////// +// Stepper configuration. +// +#define DISABLE_OUTPUTS_IDLE +#define STEPPER_MAX_SPEED 200 +#define STEPPER_ACCELERATION 25 +#define STEPPER_GEARING_FACTOR 1 + +///////////////////////////////////////////////////////////////////////////////////// +// LED blink rates (milliseconds). +// +#define LED_FAST 100 +#define LED_SLOW 500 + +///////////////////////////////////////////////////////////////////////////////////// +// Advanced options (uncomment only if needed). +// +// #define DEBUG +// #define SANITY_STEPS 10000 +// #define HOME_SENSITIVITY 300 +// #define FULL_STEP_COUNT 4096 +// #define DEBOUNCE_DELAY 10 +`, + }, +} diff --git a/src/types/typings.d.ts b/src/types/typings.d.ts new file mode 100644 index 0000000..11eaa12 --- /dev/null +++ b/src/types/typings.d.ts @@ -0,0 +1,7 @@ +declare module '*?raw' { + const content: string + export default content +} + + + diff --git a/src/vitest.config.ts b/src/vitest.config.ts new file mode 100644 index 0000000..46c519b --- /dev/null +++ b/src/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/__tests__/**/*.test.ts'], + setupFiles: ['./vitest.setup-env.js'], + }, +}) diff --git a/src/vitest.setup-env.js b/src/vitest.setup-env.js new file mode 100644 index 0000000..1438e54 --- /dev/null +++ b/src/vitest.setup-env.js @@ -0,0 +1,2 @@ +// Ensure arduino-cli is in the PATH for all tests +process.env.PATH = `/home/mstankovich/source/repositories/EX-Installer/src/bin:${process.env.PATH}`; \ No newline at end of file diff --git a/syncfusion-license.txt b/syncfusion-license.txt new file mode 100644 index 0000000..92716b4 --- /dev/null +++ b/syncfusion-license.txt @@ -0,0 +1 @@ +Ngo9BigBOggjHTQxAR8/V1JGaF5cXGpCf1FpRmJGdld5fUVHYVZUTXxaS00DNHVRdkdlWX5eeHRdQ2ZYU0F/VkpWYEs= \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_arduino_cli.py b/tests/test_arduino_cli.py new file mode 100644 index 0000000..1d3ba52 --- /dev/null +++ b/tests/test_arduino_cli.py @@ -0,0 +1,327 @@ +""" +Unit tests for ex_installer/arduino_cli.py — ArduinoCLI class + +Tests pure logic (path building, installed check, param construction, class data) +without spawning real subprocesses. No renderer, no Electron, no GUI. +""" +import os +import platform +import queue +import stat +import sys +import tempfile +from pathlib import Path +from queue import Queue +from unittest.mock import MagicMock, patch, call + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from ex_installer.arduino_cli import ArduinoCLI, ThreadedArduinoCLI, QueueMessage # noqa: E402 + + +# --------------------------------------------------------------------------- +# Class-level constants +# --------------------------------------------------------------------------- + +class TestClassConstants: + def test_arduino_cli_version_is_0_35_3(self): + assert ArduinoCLI.arduino_cli_version == "0.35.3" + + def test_base_platforms_has_arduino_avr(self): + assert "Arduino AVR" in ArduinoCLI.base_platforms + + def test_arduino_avr_platform_id(self): + assert ArduinoCLI.base_platforms["Arduino AVR"]["platform_id"] == "arduino:avr" + + def test_extra_platforms_has_esp32(self): + assert "Espressif ESP32" in ArduinoCLI.extra_platforms + + def test_esp32_platform_id(self): + assert ArduinoCLI.extra_platforms["Espressif ESP32"]["platform_id"] == "esp32:esp32" + + def test_esp32_locked_to_2_0_17(self): + # Must stay locked to avoid 3.x compile errors with EX-CommandStation + assert ArduinoCLI.extra_platforms["Espressif ESP32"]["version"] == "2.0.17" + + def test_extra_platforms_has_stm32(self): + assert "STMicroelectronics Nucleo/STM32" in ArduinoCLI.extra_platforms + + def test_supported_devices_has_mega(self): + assert "Arduino Mega or Mega 2560" in ArduinoCLI.supported_devices + + def test_mega_fqbn(self): + assert ArduinoCLI.supported_devices["Arduino Mega or Mega 2560"] == "arduino:avr:mega" + + def test_supported_devices_has_esp32_devkit(self): + assert "ESP32 Dev Kit" in ArduinoCLI.supported_devices + + def test_arduino_libraries_has_ethernet(self): + assert "Ethernet" in ArduinoCLI.arduino_libraries + + def test_dccex_devices_has_csb1(self): + assert "DCC-EX EX-CSB1" in ArduinoCLI.dccex_devices + + +# --------------------------------------------------------------------------- +# cli_file_path() +# --------------------------------------------------------------------------- + +class TestCliFilePath: + def test_returns_string(self): + cli = ArduinoCLI() + result = cli.cli_file_path() + assert isinstance(result, str) + + def test_path_contains_ex_installer(self): + cli = ArduinoCLI() + result = cli.cli_file_path() + assert "ex-installer" in result + + def test_path_contains_arduino_cli_dir(self): + cli = ArduinoCLI() + result = cli.cli_file_path() + assert "arduino-cli" in result + + def test_path_ends_with_executable_name(self): + cli = ArduinoCLI() + result = cli.cli_file_path() + if platform.system() == "Windows": + assert result.endswith("arduino-cli.exe") + else: + assert result.endswith("arduino-cli") + + def test_path_is_under_home_directory(self): + cli = ArduinoCLI() + result = cli.cli_file_path() + home = os.path.expanduser("~") + assert result.startswith(home) + + +# --------------------------------------------------------------------------- +# is_installed() +# --------------------------------------------------------------------------- + +class TestIsInstalled: + def test_returns_false_for_nonexistent_file(self): + cli = ArduinoCLI() + assert cli.is_installed("/nonexistent/path/arduino-cli") is False + + def test_returns_false_for_directory(self, tmp_path): + cli = ArduinoCLI() + assert cli.is_installed(str(tmp_path)) is False + + def test_returns_true_for_executable_file(self, tmp_path): + exe = tmp_path / "arduino-cli" + exe.write_text("#!/bin/sh\necho hi\n") + exe.chmod(exe.stat().st_mode | stat.S_IEXEC) + cli = ArduinoCLI() + assert cli.is_installed(str(exe)) is True + + def test_returns_false_for_non_executable_file(self, tmp_path): + f = tmp_path / "arduino-cli" + f.write_text("not executable") + # Remove execute permission + f.chmod(0o644) + cli = ArduinoCLI() + assert cli.is_installed(str(f)) is False + + +# --------------------------------------------------------------------------- +# compile_sketch() — parameter construction +# --------------------------------------------------------------------------- + +class TestCompileSketch: + def _compile_params(self, fqbn, sketch_dir): + """Run compile_sketch with a mocked ThreadedArduinoCLI; return captured params.""" + cli = ArduinoCLI() + q = Queue() + captured = {} + + class FakeThread: + def __init__(self, path, params, queue, *args, **kwargs): + captured["path"] = path + captured["params"] = params + + def start(self): + pass + + with patch("ex_installer.arduino_cli.ThreadedArduinoCLI", FakeThread): + with patch.object(cli, "is_installed", return_value=True): + cli.compile_sketch("/fake/arduino-cli", fqbn, sketch_dir, q) + + return captured["params"] + + def test_compile_uses_compile_subcommand(self): + params = self._compile_params("arduino:avr:mega", "/sketch") + assert params[0] == "compile" + + def test_compile_includes_fqbn_flag(self): + params = self._compile_params("arduino:avr:mega", "/sketch") + assert "-b" in params + idx = params.index("-b") + assert params[idx + 1] == "arduino:avr:mega" + + def test_compile_includes_sketch_dir(self): + params = self._compile_params("arduino:avr:mega", "/my/sketch") + assert "/my/sketch" in params + + def test_compile_uses_jsonmini_format(self): + params = self._compile_params("arduino:avr:mega", "/sketch") + assert "--format" in params + idx = params.index("--format") + assert params[idx + 1] == "jsonmini" + + def test_compile_always_starts_thread_regardless_of_path(self): + """compile_sketch does not gate on is_installed; it always spawns a thread.""" + cli = ArduinoCLI() + q = Queue() + started = [] + + class FakeThread: + def __init__(self, path, params, queue, *args, **kwargs): + pass + + def start(self): + started.append(True) + + with patch("ex_installer.arduino_cli.ThreadedArduinoCLI", FakeThread): + cli.compile_sketch("/fake/arduino-cli", "arduino:avr:mega", "/sketch", q) + + assert len(started) == 1 + + +# --------------------------------------------------------------------------- +# upload_sketch() — parameter construction +# --------------------------------------------------------------------------- + +class TestUploadSketch: + def _upload_params(self, fqbn, port="/dev/ttyACM0", sketch_dir="/sketch"): + cli = ArduinoCLI() + q = Queue() + captured = {} + + class FakeThread: + def __init__(self, path, params, queue, *args, **kwargs): + captured["params"] = params + + def start(self): + pass + + with patch("ex_installer.arduino_cli.ThreadedArduinoCLI", FakeThread): + with patch.object(cli, "is_installed", return_value=True): + cli.upload_sketch("/fake/arduino-cli", fqbn, port, sketch_dir, q) + + return captured["params"] + + def test_upload_uses_upload_subcommand(self): + params = self._upload_params("arduino:avr:mega") + assert params[0] == "upload" + + def test_upload_includes_fqbn(self): + params = self._upload_params("arduino:avr:mega") + assert "-b" in params + assert params[params.index("-b") + 1] == "arduino:avr:mega" + + def test_upload_includes_port(self): + params = self._upload_params("arduino:avr:mega", port="/dev/ttyUSB0") + assert "-p" in params + assert params[params.index("-p") + 1] == "/dev/ttyUSB0" + + def test_upload_includes_sketch_dir(self): + params = self._upload_params("arduino:avr:mega", sketch_dir="/my/sketch") + assert "/my/sketch" in params + + def test_upload_uses_jsonmini_format(self): + params = self._upload_params("arduino:avr:mega") + assert "--format" in params + assert params[params.index("--format") + 1] == "jsonmini" + + def test_esp32_upload_includes_upload_speed(self): + params = self._upload_params("esp32:esp32:esp32") + assert "--board-options" in params + idx = params.index("--board-options") + assert params[idx + 1] == "UploadSpeed=115200" + + def test_non_esp32_upload_has_no_board_options(self): + params = self._upload_params("arduino:avr:mega") + assert "--board-options" not in params + + def test_esp32_csb1_also_gets_upload_speed(self): + # Any FQBN starting with esp32:esp32 should get the option + params = self._upload_params("esp32:esp32:esp32s3") + assert "--board-options" in params + + def test_upload_always_starts_thread_regardless_of_path(self): + """upload_sketch does not gate on is_installed; it always spawns a thread.""" + cli = ArduinoCLI() + q = Queue() + started = [] + + class FakeThread: + def __init__(self, path, params, queue, *args, **kwargs): + pass + + def start(self): + started.append(True) + + with patch("ex_installer.arduino_cli.ThreadedArduinoCLI", FakeThread): + cli.upload_sketch("/fake/arduino-cli", "arduino:avr:mega", "/dev/ttyACM0", "/sketch", q) + + assert len(started) == 1 + + +# --------------------------------------------------------------------------- +# get_version() / get_platforms() — error path when not installed +# --------------------------------------------------------------------------- + +class TestNotInstalledErrors: + @pytest.mark.parametrize("method_name,args", [ + ("get_version", ["/fake/path", Queue()]), + ("get_platforms", ["/fake/path", Queue()]), + ("get_libraries", ["/fake/path", Queue()]), + ]) + def test_queues_error_message_when_not_installed(self, method_name, args): + cli = ArduinoCLI() + with patch.object(cli, "is_installed", return_value=False): + getattr(cli, method_name)(*args) + q = args[-1] + msg = q.get_nowait() + assert msg.status == "error" + assert "not installed" in msg.topic.lower() or "not installed" in msg.data.lower() + + +# --------------------------------------------------------------------------- +# download_cli() — URL selection +# --------------------------------------------------------------------------- + +class TestDownloadCli: + def test_selects_linux64_url_on_linux_64bit(self): + cli = ArduinoCLI() + q = Queue() + + captured = {} + + class FakeDownloader: + def __init__(self, url, target, queue): + captured["url"] = url + + def start(self): + pass + + with patch("ex_installer.arduino_cli.ThreadedDownloader", FakeDownloader): + with patch("platform.system", return_value="Linux"): + with patch("sys.maxsize", 2**63): + cli.download_cli(q) + + assert "Linux_64bit" in captured.get("url", "") + + def test_queues_error_for_unsupported_platform(self): + cli = ArduinoCLI() + q = Queue() + with patch("platform.system", return_value=""): + cli.download_cli(q) + msg = q.get_nowait() + assert msg.status == "error" diff --git a/tests/test_detect_boards.py b/tests/test_detect_boards.py new file mode 100644 index 0000000..74c95f7 --- /dev/null +++ b/tests/test_detect_boards.py @@ -0,0 +1,272 @@ +""" +Unit tests for src/python/detect_boards.py + +Tests serial port enumeration, JSON output format, port filtering, and error handling. +No renderer, no Electron, no GUI — pure Python logic tested in isolation. +""" +import builtins +import importlib.util +import io +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +DETECT_BOARDS_PATH = REPO_ROOT / "src" / "python" / "detect_boards.py" + + +def _make_port(device, description, manufacturer=None, vid=None, pid=None, serial_number=None): + """Create a mock serial port object.""" + port = MagicMock() + port.device = device + port.description = description + port.manufacturer = manufacturer + port.vid = vid + port.pid = pid + port.serial_number = serial_number + return port + + +def _load_module(): + """Load detect_boards module fresh each call (avoids cached import state).""" + spec = importlib.util.spec_from_file_location("detect_boards", DETECT_BOARDS_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _run_main(comports_return, argv=None): + """Run detect_boards.main() with mocked comports(); return list of parsed JSON records.""" + detect_boards = _load_module() + mock_comports = MagicMock(return_value=comports_return) + + with patch.object(detect_boards.list_ports, "comports", mock_comports): + with patch.object(sys, "argv", ["detect_boards.py"] + (argv or [])): + captured = io.StringIO() + with patch("sys.stdout", captured): + detect_boards.main() + + lines = [ln.strip() for ln in captured.getvalue().splitlines() if ln.strip()] + return [json.loads(line) for line in lines] + + +# --------------------------------------------------------------------------- +# Output format +# --------------------------------------------------------------------------- + +class TestOutputFormat: + def test_single_port_outputs_one_record(self): + records = _run_main([_make_port("/dev/ttyUSB0", "USB Serial Device")]) + assert len(records) == 1 + + def test_multiple_ports_one_record_each(self): + ports = [ + _make_port("/dev/ttyUSB0", "USB Serial Device"), + _make_port("/dev/ttyACM0", "Arduino Mega"), + ] + assert len(_run_main(ports)) == 2 + + def test_record_has_all_required_keys(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc")])[0] + for key in ("path", "description", "manufacturer", "vid", "pid", "serial_number"): + assert key in record, f"Missing key: {key}" + + def test_path_field_matches_device(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc")])[0] + assert record["path"] == "/dev/ttyUSB0" + + def test_description_field(self): + record = _run_main([_make_port("/dev/ttyUSB0", "Arduino Uno")])[0] + assert record["description"] == "Arduino Uno" + + def test_manufacturer_field(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", manufacturer="Arduino LLC")])[0] + assert record["manufacturer"] == "Arduino LLC" + + def test_manufacturer_none_serialises_as_null(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", manufacturer=None)])[0] + assert record["manufacturer"] is None + + def test_serial_number_field(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", serial_number="ABC123")])[0] + assert record["serial_number"] == "ABC123" + + def test_output_is_valid_json_on_each_line(self): + ports = [_make_port(f"/dev/ttyUSB{i}", f"Device {i}") for i in range(3)] + detect_boards = _load_module() + with patch.object(detect_boards.list_ports, "comports", return_value=ports): + with patch.object(sys, "argv", ["detect_boards.py"]): + captured = io.StringIO() + with patch("sys.stdout", captured): + detect_boards.main() + for line in captured.getvalue().splitlines(): + json.loads(line) # must not raise + + +# --------------------------------------------------------------------------- +# VID / PID hex formatting +# --------------------------------------------------------------------------- + +class TestVidPidFormatting: + def test_vid_formatted_as_4_digit_uppercase_hex(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", vid=0x2341)])[0] + assert record["vid"] == "2341" + + def test_pid_formatted_as_4_digit_uppercase_hex(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", pid=0x0043)])[0] + assert record["pid"] == "0043" + + def test_small_vid_padded_to_4_digits(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", vid=0x0001)])[0] + assert record["vid"] == "0001" + assert len(record["vid"]) == 4 + + def test_large_vid_hex(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", vid=0x10C4)])[0] + assert record["vid"] == "10C4" + + def test_vid_none_is_null(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", vid=None)])[0] + assert record["vid"] is None + + def test_pid_none_is_null(self): + record = _run_main([_make_port("/dev/ttyUSB0", "desc", pid=None)])[0] + assert record["pid"] is None + + def test_well_known_arduino_uno_vid_pid(self): + record = _run_main([_make_port("/dev/ttyACM0", "desc", vid=0x2341, pid=0x0043)])[0] + assert record["vid"] == "2341" + assert record["pid"] == "0043" + + +# --------------------------------------------------------------------------- +# No devices +# --------------------------------------------------------------------------- + +class TestNoDevices: + def test_no_ports_outputs_one_record(self): + records = _run_main([]) + assert len(records) == 1 + + def test_no_ports_record_has_info_key(self): + records = _run_main([]) + assert "info" in records[0] + + def test_no_ports_info_message(self): + records = _run_main([]) + assert records[0]["info"] == "no devices found" + + def test_no_ports_record_has_no_error_key(self): + records = _run_main([]) + assert "error" not in records[0] + + def test_no_ports_record_has_no_path_key(self): + records = _run_main([]) + assert "path" not in records[0] + + +# --------------------------------------------------------------------------- +# Port filter (--port) +# --------------------------------------------------------------------------- + +class TestPortFilter: + def test_filter_returns_only_matching_port(self): + ports = [ + _make_port("/dev/ttyUSB0", "Device A"), + _make_port("/dev/ttyACM0", "Device B"), + ] + records = _run_main(ports, argv=["--port", "/dev/ttyUSB0"]) + assert len(records) == 1 + assert records[0]["path"] == "/dev/ttyUSB0" + + def test_filter_no_match_emits_no_devices(self): + ports = [_make_port("/dev/ttyUSB0", "Device A")] + records = _run_main(ports, argv=["--port", "/dev/ttyACM99"]) + assert "info" in records[0] + + def test_filter_exact_device_match_only(self): + ports = [ + _make_port("/dev/ttyUSB0", "Device A"), + _make_port("/dev/ttyUSB1", "Device B"), + ] + records = _run_main(ports, argv=["--port", "/dev/ttyUSB0"]) + assert len(records) == 1 + assert records[0]["path"] == "/dev/ttyUSB0" + + def test_no_filter_returns_all_ports(self): + ports = [_make_port(f"/dev/ttyUSB{i}", f"Device {i}") for i in range(4)] + assert len(_run_main(ports)) == 4 + + def test_filter_preserves_full_record_content(self): + ports = [ + _make_port("/dev/ttyUSB0", "Correct", manufacturer="Mfr", vid=0x1234, pid=0xABCD), + _make_port("/dev/ttyUSB1", "Wrong"), + ] + records = _run_main(ports, argv=["--port", "/dev/ttyUSB0"]) + assert records[0]["description"] == "Correct" + assert records[0]["vid"] == "1234" + assert records[0]["pid"] == "ABCD" + + +# --------------------------------------------------------------------------- +# pyserial not installed +# --------------------------------------------------------------------------- + +class TestPyserialMissing: + def test_missing_pyserial_exits_with_code_1(self): + """Module-level ImportError causes print+exit(1).""" + real_import = builtins.__import__ + + serial_cache = {k: v for k, v in list(sys.modules.items()) + if k == "serial" or k.startswith("serial.")} + for key in serial_cache: + del sys.modules[key] + + def blocking_import(name, *args, **kwargs): + if name == "serial.tools.list_ports" or name == "serial": + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + captured = io.StringIO() + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises(SystemExit) as exc_info: + with patch("sys.stdout", captured): + spec = importlib.util.spec_from_file_location( + "detect_boards_err", DETECT_BOARDS_PATH + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + assert exc_info.value.code == 1 + sys.modules.update(serial_cache) + + def test_missing_pyserial_outputs_error_json(self): + """Module-level ImportError causes {"error": "pyserial not installed"} output.""" + real_import = builtins.__import__ + serial_cache = {k: v for k, v in list(sys.modules.items()) + if k == "serial" or k.startswith("serial.")} + for key in serial_cache: + del sys.modules[key] + + def blocking_import(name, *args, **kwargs): + if name == "serial.tools.list_ports" or name == "serial": + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + captured = io.StringIO() + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises(SystemExit): + with patch("sys.stdout", captured): + spec = importlib.util.spec_from_file_location( + "detect_boards_err2", DETECT_BOARDS_PATH + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + output = json.loads(captured.getvalue().strip()) + assert "error" in output + assert "pyserial" in output["error"].lower() + sys.modules.update(serial_cache) diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py new file mode 100644 index 0000000..5e466b4 --- /dev/null +++ b/tests/test_file_manager.py @@ -0,0 +1,300 @@ +""" +Unit tests for ex_installer/file_manager.py — FileManager static methods + +Tests path building, file read/write, directory operations, config-pattern matching, +and list extraction from files. No GUI, no renderer. +""" +import json +import os +import re +import sys +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from ex_installer.file_manager import FileManager # noqa: E402 + + +# --------------------------------------------------------------------------- +# get_base_dir() +# --------------------------------------------------------------------------- + +class TestGetBaseDir: + def test_returns_string(self): + result = FileManager.get_base_dir() + assert isinstance(result, str) + + def test_path_ends_with_ex_installer(self): + result = FileManager.get_base_dir() + assert result.endswith("ex-installer") + + def test_path_is_under_home_dir(self): + home = os.path.expanduser("~") + result = FileManager.get_base_dir() + assert result.startswith(home) + + +# --------------------------------------------------------------------------- +# get_install_dir() +# --------------------------------------------------------------------------- + +class TestGetInstallDir: + def test_returns_string(self): + result = FileManager.get_install_dir("EX-CommandStation") + assert isinstance(result, str) + + def test_contains_product_name(self): + result = FileManager.get_install_dir("EX-CommandStation") + assert "EX-CommandStation" in result + + def test_is_under_base_dir(self): + base = FileManager.get_base_dir() + result = FileManager.get_install_dir("EX-CommandStation") + assert result.startswith(base) + + +# --------------------------------------------------------------------------- +# get_temp_dir() +# --------------------------------------------------------------------------- + +class TestGetTempDir: + def test_returns_string(self): + result = FileManager.get_temp_dir() + assert isinstance(result, str) + + def test_returns_existing_directory(self): + result = FileManager.get_temp_dir() + assert os.path.isdir(result) + + def test_matches_system_tempdir(self): + result = FileManager.get_temp_dir() + assert result == tempfile.gettempdir() + + +# --------------------------------------------------------------------------- +# is_valid_dir() +# --------------------------------------------------------------------------- + +class TestIsValidDir: + def test_existing_directory_returns_true(self, tmp_path): + assert FileManager.is_valid_dir(str(tmp_path)) is True + + def test_nonexistent_directory_returns_false(self, tmp_path): + missing = str(tmp_path / "does_not_exist") + assert FileManager.is_valid_dir(missing) is False + + def test_file_path_returns_false(self, tmp_path): + f = tmp_path / "file.txt" + f.write_text("hello") + assert FileManager.is_valid_dir(str(f)) is False + + def test_empty_string_returns_false(self): + assert FileManager.is_valid_dir("") is False + + +# --------------------------------------------------------------------------- +# write_config_file() / read_config_file() +# --------------------------------------------------------------------------- + +class TestWriteReadConfigFile: + def test_write_returns_file_path_on_success(self, tmp_path): + fp = str(tmp_path / "config.h") + result = FileManager.write_config_file(fp, ["// line1\n", "// line2\n"]) + assert result == fp + + def test_written_content_is_readable(self, tmp_path): + fp = str(tmp_path / "config.h") + lines = ["#define MOTOR_SHIELD_TYPE STANDARD_MOTOR_SHIELD\n", "#define ENABLE_WIFI true\n"] + FileManager.write_config_file(fp, lines) + content = FileManager.read_config_file(fp) + assert "#define MOTOR_SHIELD_TYPE STANDARD_MOTOR_SHIELD" in content + assert "#define ENABLE_WIFI true" in content + + def test_write_creates_file(self, tmp_path): + fp = str(tmp_path / "new_file.h") + FileManager.write_config_file(fp, ["// test\n"]) + assert os.path.isfile(fp) + + def test_write_returns_error_string_for_invalid_path(self): + result = FileManager.write_config_file("/nonexistent/dir/file.h", ["content"]) + # Returns error string, not the path + assert result != "/nonexistent/dir/file.h" + assert isinstance(result, str) + + def test_read_returns_error_string_for_missing_file(self, tmp_path): + fp = str(tmp_path / "missing.h") + result = FileManager.read_config_file(fp) + assert isinstance(result, str) + # Should not raise; returns exception message + assert result != "" + + def test_write_utf8_encoding(self, tmp_path): + fp = str(tmp_path / "utf8.h") + content = ["// Ünïcödé cömment\n"] + FileManager.write_config_file(fp, content) + result = FileManager.read_config_file(fp) + assert "Ünïcödé" in result + + def test_overwrite_replaces_contents(self, tmp_path): + fp = str(tmp_path / "config.h") + FileManager.write_config_file(fp, ["// old\n"]) + FileManager.write_config_file(fp, ["// new\n"]) + content = FileManager.read_config_file(fp) + assert "// new" in content + assert "// old" not in content + + +# --------------------------------------------------------------------------- +# get_filepath() +# --------------------------------------------------------------------------- + +class TestGetFilepath: + def test_joins_dir_and_filename(self, tmp_path): + result = FileManager.get_filepath(str(tmp_path), "config.h") + assert result == os.path.join(str(tmp_path), "config.h") + + +# --------------------------------------------------------------------------- +# get_config_files() +# --------------------------------------------------------------------------- + +class TestGetConfigFiles: + def test_returns_false_for_nonexistent_dir(self, tmp_path): + missing = str(tmp_path / "no_dir") + result = FileManager.get_config_files(missing, ["config.h"]) + assert result is False + + def test_finds_exact_filename(self, tmp_path): + (tmp_path / "config.h").write_text("// config") + (tmp_path / "readme.txt").write_text("readme") + result = FileManager.get_config_files(str(tmp_path), ["config.h"]) + assert "config.h" in result + + def test_does_not_return_non_matching_file(self, tmp_path): + (tmp_path / "config.h").write_text("") + result = FileManager.get_config_files(str(tmp_path), ["config.h"]) + assert "readme.txt" not in result + + def test_regex_pattern_with_group(self, tmp_path): + # Pattern matches myConfig.h but group captures it + (tmp_path / "myConfig.h").write_text("") + pattern = r"^(myConfig\.h)$" + result = FileManager.get_config_files(str(tmp_path), [pattern]) + assert "myConfig.h" in result + + def test_commandstation_myautomation_pattern(self, tmp_path): + # Matches myAutomation.h via the standard EX-CommandStation pattern + (tmp_path / "myAutomation.h").write_text("") + pattern = r"^my.*\.[^?]*example\.h$|(^my.*\.h$)" + result = FileManager.get_config_files(str(tmp_path), [pattern]) + assert "myAutomation.h" in result + + def test_empty_dir_returns_empty_list(self, tmp_path): + result = FileManager.get_config_files(str(tmp_path), ["config.h"]) + assert result == [] + + def test_multiple_patterns_matched(self, tmp_path): + (tmp_path / "config.h").write_text("") + (tmp_path / "myConfig.h").write_text("") + patterns = ["config.h", r"^(myConfig\.h)$"] + result = FileManager.get_config_files(str(tmp_path), patterns) + assert "config.h" in result + assert "myConfig.h" in result + + +# --------------------------------------------------------------------------- +# get_list_from_file() +# --------------------------------------------------------------------------- + +class TestGetListFromFile: + def test_returns_false_for_missing_file(self, tmp_path): + fp = str(tmp_path / "missing.txt") + result = FileManager.get_list_from_file(fp, r"(.*)") + assert result is False + + def test_extracts_matching_group(self, tmp_path): + fp = str(tmp_path / "boards.txt") + Path(fp).write_text("arduino:avr:mega\narduino:avr:uno\n") + result = FileManager.get_list_from_file(fp, r"(arduino:avr:\w+)") + assert "arduino:avr:mega" in result + assert "arduino:avr:uno" in result + + def test_deduplicates_results(self, tmp_path): + fp = str(tmp_path / "data.txt") + Path(fp).write_text("foo\nfoo\nbar\n") + result = FileManager.get_list_from_file(fp, r"(foo|bar)") + assert result.count("foo") == 1 + + def test_returns_empty_list_when_no_matches(self, tmp_path): + fp = str(tmp_path / "data.txt") + Path(fp).write_text("no match here\n") + result = FileManager.get_list_from_file(fp, r"(NOTFOUND)") + assert result == [] + + +# --------------------------------------------------------------------------- +# rename_dir() +# --------------------------------------------------------------------------- + +class TestRenameDir: + def test_renames_existing_directory(self, tmp_path): + src = tmp_path / "source" + dst = tmp_path / "target" + src.mkdir() + result = FileManager.rename_dir(str(src), str(dst)) + assert result is True + assert dst.is_dir() + assert not src.exists() + + def test_returns_false_for_nonexistent_source(self, tmp_path): + src = str(tmp_path / "no_source") + dst = str(tmp_path / "target") + result = FileManager.rename_dir(src, dst) + assert result is False + + def test_returns_false_when_target_already_exists(self, tmp_path): + src = tmp_path / "source" + dst = tmp_path / "target" + src.mkdir() + dst.mkdir() + result = FileManager.rename_dir(str(src), str(dst)) + assert result is False + + +# --------------------------------------------------------------------------- +# copy_config_files() / delete_config_files() +# --------------------------------------------------------------------------- + +class TestCopyAndDeleteConfigFiles: + def test_copy_returns_none_on_success(self, tmp_path): + src_dir = tmp_path / "src" + dst_dir = tmp_path / "dst" + src_dir.mkdir() + dst_dir.mkdir() + (src_dir / "config.h").write_text("// config") + result = FileManager.copy_config_files(str(src_dir), str(dst_dir), ["config.h"]) + assert result is None + assert (dst_dir / "config.h").is_file() + + def test_copy_returns_failed_list_on_error(self, tmp_path): + src_dir = str(tmp_path / "src") + dst_dir = str(tmp_path / "dst") + os.makedirs(dst_dir) + result = FileManager.copy_config_files(src_dir, dst_dir, ["missing.h"]) + assert isinstance(result, list) + assert "missing.h" in result + + def test_delete_returns_none_on_success(self, tmp_path): + (tmp_path / "config.h").write_text("") + result = FileManager.delete_config_files(str(tmp_path), ["config.h"]) + assert result is None + assert not (tmp_path / "config.h").exists() + + def test_delete_returns_failed_list_when_file_missing(self, tmp_path): + result = FileManager.delete_config_files(str(tmp_path), ["nonexistent.h"]) + assert isinstance(result, list) + assert "nonexistent.h" in result diff --git a/tests/test_git_client.py b/tests/test_git_client.py new file mode 100644 index 0000000..dabd62e --- /dev/null +++ b/tests/test_git_client.py @@ -0,0 +1,119 @@ +""" +Unit tests for ex_installer/git_client.py — GitClient static methods + +Tests pure logic: version string parsing, directory validation, helper functions. +No network, no real repository cloning, no GUI. +""" +import os +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from ex_installer.git_client import GitClient, get_exception # noqa: E402 + + +# --------------------------------------------------------------------------- +# get_exception() +# --------------------------------------------------------------------------- + +class TestGetException: + def test_returns_string(self): + result = get_exception(ValueError("test message")) + assert isinstance(result, str) + + def test_includes_exception_type(self): + result = get_exception(ValueError("oops")) + assert "ValueError" in result + + def test_includes_exception_args(self): + result = get_exception(RuntimeError("something went wrong")) + assert "something went wrong" in result + + def test_works_with_ioerror(self): + result = get_exception(IOError("disk full")) + assert isinstance(result, str) + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# extract_version_details() +# --------------------------------------------------------------------------- + +class TestExtractVersionDetails: + def test_parses_prod_version(self): + result = GitClient.extract_version_details("v5.0.0-Prod") + assert result == (5, 0, 0) + + def test_parses_devel_version(self): + result = GitClient.extract_version_details("v4.2.67-Devel") + assert result == (4, 2, 67) + + def test_parses_major_version(self): + major, minor, patch = GitClient.extract_version_details("v10.0.0-Prod") + assert major == 10 + + def test_parses_minor_version(self): + major, minor, patch = GitClient.extract_version_details("v1.3.0-Prod") + assert minor == 3 + + def test_parses_patch_version(self): + major, minor, patch = GitClient.extract_version_details("v1.0.7-Prod") + assert patch == 7 + + def test_returns_none_tuple_for_invalid_string(self): + result = GitClient.extract_version_details("not-a-version") + assert result == (None, None, None) + + def test_returns_none_tuple_for_empty_string(self): + result = GitClient.extract_version_details("") + assert result == (None, None, None) + + def test_returns_none_for_partial_version(self): + # Missing -Prod/-Devel suffix + result = GitClient.extract_version_details("v1.2.3") + assert result == (None, None, None) + + def test_returns_none_for_wrong_tag_type(self): + # "Beta" is not a recognised type + result = GitClient.extract_version_details("v1.2.3-Beta") + assert result == (None, None, None) + + def test_multi_digit_patch(self): + major, minor, patch = GitClient.extract_version_details("v4.2.258-Devel") + assert patch == 258 + + def test_returns_integers_not_strings(self): + major, minor, patch = GitClient.extract_version_details("v3.1.4-Prod") + assert isinstance(major, int) + assert isinstance(minor, int) + assert isinstance(patch, int) + + +# --------------------------------------------------------------------------- +# dir_is_git_repo() +# --------------------------------------------------------------------------- + +class TestDirIsGitRepo: + def test_returns_true_for_dir_with_dot_git(self, tmp_path): + git_file = tmp_path / ".git" + git_file.write_text("gitdir: ...") + assert GitClient.dir_is_git_repo(str(tmp_path)) is True + + def test_returns_false_for_dir_without_dot_git(self, tmp_path): + assert GitClient.dir_is_git_repo(str(tmp_path)) is False + + def test_returns_false_for_nonexistent_dir(self, tmp_path): + missing = str(tmp_path / "no_such_dir") + assert GitClient.dir_is_git_repo(missing) is False + + def test_dot_git_as_directory_also_works(self, tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + assert GitClient.dir_is_git_repo(str(tmp_path)) is True + + def test_returns_false_for_empty_string(self): + assert GitClient.dir_is_git_repo("") is False diff --git a/tests/test_product_details.py b/tests/test_product_details.py new file mode 100644 index 0000000..853be78 --- /dev/null +++ b/tests/test_product_details.py @@ -0,0 +1,162 @@ +""" +Unit tests for ex_installer/product_details.py + +Verifies the product registry schema, URLs, device support, and required config files. +No GUI, no renderer — pure data structure validation. +""" +import sys +from pathlib import Path + +import pytest + +# Allow importing ex_installer as a package from the repo root +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from ex_installer.product_details import product_details # noqa: E402 + + +REQUIRED_PRODUCT_KEYS = { + "product_name", + "repo_name", + "repo_url", + "default_branch", + "supported_devices", + "minimum_config_files", +} + +KNOWN_PRODUCTS = ["ex_commandstation", "ex_ioexpander", "ex_turntable"] + + +# --------------------------------------------------------------------------- +# Registry completeness +# --------------------------------------------------------------------------- + +class TestRegistryCompleteness: + def test_all_known_products_present(self): + for product in KNOWN_PRODUCTS: + assert product in product_details, f"Missing product: {product}" + + def test_no_empty_registry(self): + assert len(product_details) > 0 + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_product_has_all_required_keys(self, product): + entry = product_details[product] + for key in REQUIRED_PRODUCT_KEYS: + assert key in entry, f"Product '{product}' missing key: {key}" + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_product_name_is_non_empty_string(self, product): + assert isinstance(product_details[product]["product_name"], str) + assert len(product_details[product]["product_name"]) > 0 + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_repo_url_points_to_github(self, product): + url = product_details[product]["repo_url"] + assert url.startswith("https://github.com/DCC-EX/"), f"Unexpected URL: {url}" + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_repo_url_ends_with_git(self, product): + url = product_details[product]["repo_url"] + assert url.endswith(".git"), f"URL does not end with .git: {url}" + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_supported_devices_is_non_empty_list(self, product): + devices = product_details[product]["supported_devices"] + assert isinstance(devices, list) + assert len(devices) > 0 + + @pytest.mark.parametrize("product", KNOWN_PRODUCTS) + def test_minimum_config_files_is_non_empty_list(self, product): + files = product_details[product]["minimum_config_files"] + assert isinstance(files, list) + assert len(files) > 0 + + +# --------------------------------------------------------------------------- +# EX-CommandStation specifics +# --------------------------------------------------------------------------- + +class TestCommandStation: + def setup_method(self): + self.entry = product_details["ex_commandstation"] + + def test_product_name(self): + assert self.entry["product_name"] == "EX-CommandStation" + + def test_default_branch_is_master(self): + assert self.entry["default_branch"] == "master" + + def test_repo_name(self): + assert self.entry["repo_name"] == "DCC-EX/CommandStation-EX" + + def test_supports_arduino_mega(self): + assert "arduino:avr:mega" in self.entry["supported_devices"] + + def test_supports_arduino_uno(self): + assert "arduino:avr:uno" in self.entry["supported_devices"] + + def test_supports_arduino_nano(self): + assert "arduino:avr:nano" in self.entry["supported_devices"] + + def test_supports_esp32(self): + assert "esp32:esp32:esp32" in self.entry["supported_devices"] + + def test_supports_nucleo_f411re(self): + assert "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F411RE" in self.entry["supported_devices"] + + def test_supports_nucleo_f446re(self): + assert "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE" in self.entry["supported_devices"] + + def test_minimum_config_files_includes_config_h(self): + assert "config.h" in self.entry["minimum_config_files"] + + +# --------------------------------------------------------------------------- +# EX-IOExpander specifics +# --------------------------------------------------------------------------- + +class TestIOExpander: + def setup_method(self): + self.entry = product_details["ex_ioexpander"] + + def test_product_name(self): + assert self.entry["product_name"] == "EX-IOExpander" + + def test_default_branch_is_main(self): + assert self.entry["default_branch"] == "main" + + def test_minimum_config_files_includes_myconfig_h(self): + assert "myConfig.h" in self.entry["minimum_config_files"] + + def test_supports_arduino_nano(self): + assert "arduino:avr:nano" in self.entry["supported_devices"] + + def test_does_not_support_esp32(self): + # IOExpander is AVR/STM32 only — no ESP32 in its supported list + assert "esp32:esp32:esp32" not in self.entry["supported_devices"] + + +# --------------------------------------------------------------------------- +# EX-Turntable specifics +# --------------------------------------------------------------------------- + +class TestTurntable: + def setup_method(self): + self.entry = product_details["ex_turntable"] + + def test_product_name(self): + assert self.entry["product_name"] == "EX-Turntable" + + def test_default_branch_is_main(self): + assert self.entry["default_branch"] == "main" + + def test_minimum_config_files_includes_config_h(self): + assert "config.h" in self.entry["minimum_config_files"] + + def test_supports_arduino_uno(self): + assert "arduino:avr:uno" in self.entry["supported_devices"] + + def test_supports_arduino_nano(self): + assert "arduino:avr:nano" in self.entry["supported_devices"]