From 2841478b531e74f25b351a5be5be6621cdaef722 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:49:49 -0700 Subject: [PATCH 1/9] fix(engine): make a simulation run mean something, and ship a working wheel EoSim is the healthiest repo in the org - 1645 tests passing before this change. But `eosim run stm32f4` printed "PASSED (10000 cycles)" while executing nothing, and the published wheel could not run at all. Both are fixed. THE RUN REPORTED SUCCESS FOR DOING NOTHING _run_eosim() never loaded firmware. It built a VirtualMachine over zeroed memory, stepped the CPU 10000 times through NOPs, and VirtualMachine.run() returned the literal `success: True` after printing "EoS booted successfully" unconditionally. No EoS was involved and nothing distinguished that from a real boot. run() now reports why it stopped - 'no-firmware', 'halted', 'cycle-limit' or 'timeout' - and success is derived: only a clean halt counts. Exhausting the cycle budget or the clock means we stopped it, not that it finished. `eosim run` gained --firmware and exits 2 with "NO FIRMWARE" when given none. Measured, from a clean wheel install: eosim run stm32f4 -> NO FIRMWARE, exit 2 eosim run stm32f4 --firmware fw.bin -> PASSED (3 cycles, halted), exit 0 THE WHEEL WAS BROKEN No EoSim release has ever published a wheel, so this had never been exercised. Building one showed why it matters: `platforms/` sat at the repository root and was addressed as EOSIM_ROOT/"platforms" where EOSIM_ROOT is site-packages once installed. Every `eosim run` from a wheel died with FileNotFoundError on site-packages/platforms. An editable install hides this completely. Moved the 150 platform directories to eosim/platforms so the package is self-contained, declared them as package-data, and made the lookup prefer the packaged location with a fallback for older layouts. Verified: wheel contains 149 platform.yml; a fresh venv installs it and `eosim stats` reports 149 platforms. THE CPU IS REAL - now proven eosim/engine/native/cpu implements an ARM32 subset (MOV imm, B, LDR, STR, BX LR, SVC, UDF) and genuinely decodes and executes. tests/unit/test_native_engine_ execution.py hand-assembles instructions and asserts the resulting register and memory state, so this is measured rather than assumed. Also fixed the cycle accounting: the halting instruction retired but was not counted, leaving run()['cycles'] one behind cpu.state.cycles for every program that halts. I CHANGED FIVE EXISTING TESTS - flagging this explicitly test_core::test_vm_run, test_gui::test_vm_uart_output, test_gui::test_vm_run_stop_lifecycle, test_cli::test_run_eosim_engine and test_engines_and_integrations::TestEoSimEngineRun::test_run all ran with NO firmware and asserted success. They pinned the defect. Each now loads a real image and asserts the real outcome, and each gained a companion asserting that the no-firmware path is a failure. Nothing was deleted or skipped. Two integration tests started skipping with "platforms/ directory not found" after the move - a silent disable, which is worse than a failure. Repointed; test_validate_all_real_platforms now validates all 149 configs for real. ALSO --version said 2.0.0 while pyproject and eosim.__version__ said 3.0.1. Now sourced from __version__. Log output used escaped \\n, so every log arrived as one long line with literal backslash-n. Four sites fixed. Package metadata claimed "Development Status :: 5 - Production/Stable" and "World's most powerful universal simulation platform - supersedes 250+ tools across 20 domains". Now Beta, with a description of what it is. CI counted platform.yml at the old path. Verified: 1659 passed, 3 skipped (pre-existing: tkinter absent), 0 failed. Coverage 60.87%. ruff clean on the file I added; the repo has 490 pre-existing ruff errors, untouched and unrelated. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 77824 -> 548864 bytes .github/workflows/ci.yml | 2 +- eosim.egg-info/PKG-INFO | 14 +- eosim.egg-info/SOURCES.txt | 203 ++ eosim/cli/main.py | 1947 ++++++++-------- eosim/engine/native/__init__.py | 73 +- .../platforms}/adi-aducm4050/platform.yml | 0 .../platforms}/aerodynamics-sim/platform.yml | 0 .../platforms}/aerodynamics-sim/tests.yml | 0 .../platforms}/agriculture-sim/platform.yml | 0 .../platforms}/allwinner-d1/platform.yml | 0 .../platforms}/am62x/platform.yml | 0 .../platforms}/am64x/platform.yml | 0 .../platforms}/am64x/tests.yml | 0 .../platforms}/ambiq-apollo4/platform.yml | 0 .../platforms}/android-arm64/platform.yml | 0 .../platforms}/android-tv/platform.yml | 0 .../platforms}/android-x86/platform.yml | 0 .../platforms}/apple-m1/platform.yml | 0 .../platforms}/apple-tv/platform.yml | 0 .../platforms}/arc-em/platform.yml | 0 .../platforms}/arc-em/tests.yml | 0 .../platforms}/arm-ethos-u55/platform.yml | 0 .../platforms}/arm-mcu/nrf52.yml | 0 .../platforms}/arm-mcu/platform.yml | 0 .../platforms}/arm-mcu/rp2040.yml | 0 .../platforms}/arm-mcu/tests.yml | 0 .../platforms}/arm-vexpress/platform.yml | 0 {platforms => eosim/platforms}/arm64/eos.yml | 0 .../platforms}/arm64/platform.yml | 0 .../platforms}/arm64/tests.yml | 0 .../platforms}/atmega2560/platform.yml | 0 .../platforms}/atmega328p/platform.yml | 0 .../platforms}/attiny85/platform.yml | 0 .../platforms}/aurix-tc3xx/platform.yml | 0 .../platforms}/aurix-tc3xx/tests.yml | 0 .../platforms}/beaglebone/platform.yml | 0 .../platforms}/beaglebone/tests.yml | 0 .../platforms}/bl602/platform.yml | 0 .../platforms}/bl706/platform.yml | 0 .../platforms}/canaan-k510/platform.yml | 0 .../platforms}/cc2652/platform.yml | 0 .../platforms}/cc3220/platform.yml | 0 .../platforms}/ch32v307/platform.yml | 0 .../cisco-catalyst-embedded/platform.yml | 0 .../platforms}/construction-sim/platform.yml | 0 .../platforms}/cortex-r5/platform.yml | 0 .../platforms}/cortex-r5/tests.yml | 0 .../platforms}/cortex-r52/platform.yml | 0 .../platforms}/cortex-r52/tests.yml | 0 .../platforms}/dialog-da14695/platform.yml | 0 .../platforms}/efm32gg/platform.yml | 0 .../platforms}/efr32bg22/platform.yml | 0 .../platforms}/efr32mg24/platform.yml | 0 .../platforms}/ericsson-baseband/platform.yml | 0 .../platforms}/esp32/platform.yml | 0 .../platforms}/esp32/tests.yml | 0 .../platforms}/esp32c3/platform.yml | 0 .../platforms}/esp32c3/tests.yml | 0 .../platforms}/esp32s3/platform.yml | 0 .../platforms}/esp32s3/tests.yml | 0 .../platforms}/finance-sim/platform.yml | 0 .../platforms}/finance-sim/tests.yml | 0 .../platforms}/fire-tv/platform.yml | 0 .../platforms}/gaming-sim/platform.yml | 0 .../platforms}/gaming-sim/tests.yml | 0 .../platforms}/gd32f303/platform.yml | 0 .../platforms}/gd32vf103/platform.yml | 0 .../platforms}/gd32vf103/tests.yml | 0 .../platforms}/google-coral/platform.yml | 0 .../platforms}/gowin-gw1n/platform.yml | 0 .../platforms}/hailo-8/platform.yml | 0 .../platforms}/holtek-ht32/platform.yml | 0 .../platforms}/huawei-kunpeng/platform.yml | 0 .../platforms}/imx8m/platform.yml | 0 .../platforms}/imx8m/tests.yml | 0 .../platforms}/imxrt1060/platform.yml | 0 .../platforms}/infineon-tc4xx/platform.yml | 0 .../platforms}/intel-cyclone-v/platform.yml | 0 .../platforms}/intel-movidius/platform.yml | 0 .../platforms}/ios-arm64/platform.yml | 0 .../platforms}/jetson-nano/platform.yml | 0 .../platforms}/jetson-nano/tests.yml | 0 .../platforms}/jetson-orin/platform.yml | 0 .../platforms}/jetson-orin/tests.yml | 0 .../platforms}/k64f/platform.yml | 0 {platforms => eosim/platforms}/k64f/tests.yml | 0 .../platforms}/kendryte-k210/platform.yml | 0 .../platforms}/kendryte-k210/tests.yml | 0 .../platforms}/kneron-kl520/platform.yml | 0 .../platforms}/lattice-ecp5/platform.yml | 0 .../platforms}/lattice-ice40/platform.yml | 0 .../platforms}/logistics-sim/platform.yml | 0 .../platforms}/lpc4088/platform.yml | 0 .../platforms}/lpc55s69/platform.yml | 0 .../platforms}/maritime-sim/platform.yml | 0 .../platforms}/max32660/platform.yml | 0 .../platforms}/mcxn947/platform.yml | 0 .../platforms}/mediatek-mt7688/platform.yml | 0 .../platforms}/microblaze/platform.yml | 0 .../platforms}/microblaze/tests.yml | 0 .../mikrotik-routerboard/platform.yml | 0 .../platforms}/milkv-duo/platform.yml | 0 .../platforms}/mining-sim/platform.yml | 0 .../platforms}/mipsel/platform.yml | 0 .../platforms}/msp430/platform.yml | 0 .../platforms}/mythic-amp/platform.yml | 0 .../platforms}/nokia-fpga-radio/platform.yml | 0 .../platforms}/nrf52/platform.yml | 0 .../platforms}/nrf52/tests.yml | 0 .../platforms}/nrf5340/platform.yml | 0 .../platforms}/nrf9160/platform.yml | 0 .../platforms}/nuclear-sim/platform.yml | 0 .../platforms}/nuvoton-m480/platform.yml | 0 .../platforms}/nxp-s32g/platform.yml | 0 .../platforms}/omap-l138/platform.yml | 0 .../platforms}/onsemi-rsl10/platform.yml | 0 .../platforms}/physiology-sim/platform.yml | 0 .../platforms}/physiology-sim/tests.yml | 0 .../platforms}/pic18f/platform.yml | 0 .../platforms}/pic32mx/platform.yml | 0 .../platforms}/pic32mz/platform.yml | 0 .../platforms}/ppce500/platform.yml | 0 .../platforms}/ppce500/tests.yml | 0 .../platforms}/ps5-embedded/platform.yml | 0 .../platforms}/psoc6/platform.yml | 0 .../platforms}/qemu-q35/platform.yml | 0 .../platforms}/qemu-q35/tests.yml | 0 .../platforms}/qualcomm-qcs610/platform.yml | 0 .../platforms}/ra4m1/platform.yml | 0 .../platforms}/railway-sim/platform.yml | 0 .../platforms}/raspi-zero2w/platform.yml | 0 .../platforms}/raspi-zero2w/tests.yml | 0 .../platforms}/raspi2b/platform.yml | 0 .../platforms}/raspi2b/tests.yml | 0 .../platforms}/raspi3/platform.yml | 0 .../platforms}/raspi3/tests.yml | 0 .../platforms}/raspi4/platform.yml | 0 .../platforms}/raspi4/tests.yml | 0 .../platforms}/raspi5/platform.yml | 0 .../platforms}/raspi5/tests.yml | 0 .../platforms}/rcar-h3/platform.yml | 0 .../platforms}/rcar-s4/platform.yml | 0 .../platforms}/renesas-ra6m5/platform.yml | 0 .../platforms}/renesas-rcar-s4/platform.yml | 0 .../platforms}/renesas-rh850/platform.yml | 0 .../platforms}/riscv64/eos.yml | 0 .../platforms}/riscv64/platform.yml | 0 .../platforms}/riscv64/tests.yml | 0 .../platforms}/rl78/platform.yml | 0 .../platforms}/roku-tv/platform.yml | 0 .../platforms}/rp2040/platform.yml | 0 .../platforms}/rp2040/tests.yml | 0 .../platforms}/rx65n/platform.yml | 0 .../platforms}/rza2m/platform.yml | 0 .../platforms}/s32g274a/platform.yml | 0 .../platforms}/s32k344/platform.yml | 0 .../platforms}/s32z/platform.yml | 0 .../platforms}/samc21/platform.yml | 0 .../platforms}/samd21/platform.yml | 0 .../platforms}/samd51/platform.yml | 0 .../platforms}/samd51/tests.yml | 0 .../platforms}/same70/platform.yml | 0 .../platforms}/saml21/platform.yml | 0 .../samsung-exynos-auto-v9/platform.yml | 0 .../platforms}/si7021/platform.yml | 0 .../platforms}/sifive_u/platform.yml | 0 .../platforms}/sifive_u/tests.yml | 0 .../platforms}/smart-city-sim/platform.yml | 0 .../platforms}/starfive-jh7110/platform.yml | 0 .../steamdeck-embedded/platform.yml | 0 .../platforms}/stm32f4/platform.yml | 0 .../platforms}/stm32f4/tests.yml | 0 .../platforms}/stm32h7/platform.yml | 0 .../platforms}/stm32h7/tests.yml | 0 .../platforms}/stm32l4/platform.yml | 0 .../platforms}/stm32l4/tests.yml | 0 .../platforms}/stm32mp1/platform.yml | 0 .../platforms}/stm32mp1/tests.yml | 0 .../platforms}/switch-embedded/platform.yml | 0 .../platforms}/tda4vm/platform.yml | 0 .../platforms}/templates/arm64-board.yml | 0 .../platforms}/templates/mcu-board.yml | 0 .../platforms}/ti-msp432/platform.yml | 0 .../platforms}/ti-tda4vm/platform.yml | 0 .../platforms}/ti-tms570/platform.yml | 0 .../platforms}/tizen-tv/platform.yml | 0 .../platforms}/tms320/platform.yml | 0 .../ubiquiti-edgerouter/platform.yml | 0 .../platforms}/versatilepb/platform.yml | 0 .../platforms}/versatilepb/tests.yml | 0 .../platforms}/vexpress-a15/platform.yml | 0 .../platforms}/vexpress-a15/tests.yml | 0 .../platforms}/vexpress-a9/platform.yml | 0 .../platforms}/vexpress-a9/tests.yml | 0 .../platforms}/weather-sim/platform.yml | 0 .../platforms}/weather-sim/tests.yml | 0 .../platforms}/webos-tv/platform.yml | 0 .../platforms}/x86_64/platform.yml | 0 .../platforms}/x86_64/tests.yml | 0 .../xilinx-versal-auto/platform.yml | 0 .../platforms}/xilinx-versal/platform.yml | 0 .../platforms}/xilinx-zynq7020/platform.yml | 0 .../platforms}/xtensa-esp/platform.yml | 0 .../platforms}/xtensa-esp/tests.yml | 0 out/logs/stm32f4.log | 5 + pyproject.toml | 10 +- tests/integration/test_platform_pipeline.py | 4 +- tests/unit/test_cli.py | 535 ++--- tests/unit/test_core.py | 1379 ++++++------ tests/unit/test_engines_and_integrations.py | 485 ++-- tests/unit/test_gui.py | 1981 +++++++++-------- tests/unit/test_native_engine_execution.py | 141 ++ 213 files changed, 3654 insertions(+), 3125 deletions(-) rename {platforms => eosim/platforms}/adi-aducm4050/platform.yml (100%) rename {platforms => eosim/platforms}/aerodynamics-sim/platform.yml (100%) rename {platforms => eosim/platforms}/aerodynamics-sim/tests.yml (100%) rename {platforms => eosim/platforms}/agriculture-sim/platform.yml (100%) rename {platforms => eosim/platforms}/allwinner-d1/platform.yml (100%) rename {platforms => eosim/platforms}/am62x/platform.yml (100%) rename {platforms => eosim/platforms}/am64x/platform.yml (100%) rename {platforms => eosim/platforms}/am64x/tests.yml (100%) rename {platforms => eosim/platforms}/ambiq-apollo4/platform.yml (100%) rename {platforms => eosim/platforms}/android-arm64/platform.yml (100%) rename {platforms => eosim/platforms}/android-tv/platform.yml (100%) rename {platforms => eosim/platforms}/android-x86/platform.yml (100%) rename {platforms => eosim/platforms}/apple-m1/platform.yml (100%) rename {platforms => eosim/platforms}/apple-tv/platform.yml (100%) rename {platforms => eosim/platforms}/arc-em/platform.yml (100%) rename {platforms => eosim/platforms}/arc-em/tests.yml (100%) rename {platforms => eosim/platforms}/arm-ethos-u55/platform.yml (100%) rename {platforms => eosim/platforms}/arm-mcu/nrf52.yml (100%) rename {platforms => eosim/platforms}/arm-mcu/platform.yml (100%) rename {platforms => eosim/platforms}/arm-mcu/rp2040.yml (100%) rename {platforms => eosim/platforms}/arm-mcu/tests.yml (100%) rename {platforms => eosim/platforms}/arm-vexpress/platform.yml (100%) rename {platforms => eosim/platforms}/arm64/eos.yml (100%) rename {platforms => eosim/platforms}/arm64/platform.yml (100%) rename {platforms => eosim/platforms}/arm64/tests.yml (100%) rename {platforms => eosim/platforms}/atmega2560/platform.yml (100%) rename {platforms => eosim/platforms}/atmega328p/platform.yml (100%) rename {platforms => eosim/platforms}/attiny85/platform.yml (100%) rename {platforms => eosim/platforms}/aurix-tc3xx/platform.yml (100%) rename {platforms => eosim/platforms}/aurix-tc3xx/tests.yml (100%) rename {platforms => eosim/platforms}/beaglebone/platform.yml (100%) rename {platforms => eosim/platforms}/beaglebone/tests.yml (100%) rename {platforms => eosim/platforms}/bl602/platform.yml (100%) rename {platforms => eosim/platforms}/bl706/platform.yml (100%) rename {platforms => eosim/platforms}/canaan-k510/platform.yml (100%) rename {platforms => eosim/platforms}/cc2652/platform.yml (100%) rename {platforms => eosim/platforms}/cc3220/platform.yml (100%) rename {platforms => eosim/platforms}/ch32v307/platform.yml (100%) rename {platforms => eosim/platforms}/cisco-catalyst-embedded/platform.yml (100%) rename {platforms => eosim/platforms}/construction-sim/platform.yml (100%) rename {platforms => eosim/platforms}/cortex-r5/platform.yml (100%) rename {platforms => eosim/platforms}/cortex-r5/tests.yml (100%) rename {platforms => eosim/platforms}/cortex-r52/platform.yml (100%) rename {platforms => eosim/platforms}/cortex-r52/tests.yml (100%) rename {platforms => eosim/platforms}/dialog-da14695/platform.yml (100%) rename {platforms => eosim/platforms}/efm32gg/platform.yml (100%) rename {platforms => eosim/platforms}/efr32bg22/platform.yml (100%) rename {platforms => eosim/platforms}/efr32mg24/platform.yml (100%) rename {platforms => eosim/platforms}/ericsson-baseband/platform.yml (100%) rename {platforms => eosim/platforms}/esp32/platform.yml (100%) rename {platforms => eosim/platforms}/esp32/tests.yml (100%) rename {platforms => eosim/platforms}/esp32c3/platform.yml (100%) rename {platforms => eosim/platforms}/esp32c3/tests.yml (100%) rename {platforms => eosim/platforms}/esp32s3/platform.yml (100%) rename {platforms => eosim/platforms}/esp32s3/tests.yml (100%) rename {platforms => eosim/platforms}/finance-sim/platform.yml (100%) rename {platforms => eosim/platforms}/finance-sim/tests.yml (100%) rename {platforms => eosim/platforms}/fire-tv/platform.yml (100%) rename {platforms => eosim/platforms}/gaming-sim/platform.yml (100%) rename {platforms => eosim/platforms}/gaming-sim/tests.yml (100%) rename {platforms => eosim/platforms}/gd32f303/platform.yml (100%) rename {platforms => eosim/platforms}/gd32vf103/platform.yml (100%) rename {platforms => eosim/platforms}/gd32vf103/tests.yml (100%) rename {platforms => eosim/platforms}/google-coral/platform.yml (100%) rename {platforms => eosim/platforms}/gowin-gw1n/platform.yml (100%) rename {platforms => eosim/platforms}/hailo-8/platform.yml (100%) rename {platforms => eosim/platforms}/holtek-ht32/platform.yml (100%) rename {platforms => eosim/platforms}/huawei-kunpeng/platform.yml (100%) rename {platforms => eosim/platforms}/imx8m/platform.yml (100%) rename {platforms => eosim/platforms}/imx8m/tests.yml (100%) rename {platforms => eosim/platforms}/imxrt1060/platform.yml (100%) rename {platforms => eosim/platforms}/infineon-tc4xx/platform.yml (100%) rename {platforms => eosim/platforms}/intel-cyclone-v/platform.yml (100%) rename {platforms => eosim/platforms}/intel-movidius/platform.yml (100%) rename {platforms => eosim/platforms}/ios-arm64/platform.yml (100%) rename {platforms => eosim/platforms}/jetson-nano/platform.yml (100%) rename {platforms => eosim/platforms}/jetson-nano/tests.yml (100%) rename {platforms => eosim/platforms}/jetson-orin/platform.yml (100%) rename {platforms => eosim/platforms}/jetson-orin/tests.yml (100%) rename {platforms => eosim/platforms}/k64f/platform.yml (100%) rename {platforms => eosim/platforms}/k64f/tests.yml (100%) rename {platforms => eosim/platforms}/kendryte-k210/platform.yml (100%) rename {platforms => eosim/platforms}/kendryte-k210/tests.yml (100%) rename {platforms => eosim/platforms}/kneron-kl520/platform.yml (100%) rename {platforms => eosim/platforms}/lattice-ecp5/platform.yml (100%) rename {platforms => eosim/platforms}/lattice-ice40/platform.yml (100%) rename {platforms => eosim/platforms}/logistics-sim/platform.yml (100%) rename {platforms => eosim/platforms}/lpc4088/platform.yml (100%) rename {platforms => eosim/platforms}/lpc55s69/platform.yml (100%) rename {platforms => eosim/platforms}/maritime-sim/platform.yml (100%) rename {platforms => eosim/platforms}/max32660/platform.yml (100%) rename {platforms => eosim/platforms}/mcxn947/platform.yml (100%) rename {platforms => eosim/platforms}/mediatek-mt7688/platform.yml (100%) rename {platforms => eosim/platforms}/microblaze/platform.yml (100%) rename {platforms => eosim/platforms}/microblaze/tests.yml (100%) rename {platforms => eosim/platforms}/mikrotik-routerboard/platform.yml (100%) rename {platforms => eosim/platforms}/milkv-duo/platform.yml (100%) rename {platforms => eosim/platforms}/mining-sim/platform.yml (100%) rename {platforms => eosim/platforms}/mipsel/platform.yml (100%) rename {platforms => eosim/platforms}/msp430/platform.yml (100%) rename {platforms => eosim/platforms}/mythic-amp/platform.yml (100%) rename {platforms => eosim/platforms}/nokia-fpga-radio/platform.yml (100%) rename {platforms => eosim/platforms}/nrf52/platform.yml (100%) rename {platforms => eosim/platforms}/nrf52/tests.yml (100%) rename {platforms => eosim/platforms}/nrf5340/platform.yml (100%) rename {platforms => eosim/platforms}/nrf9160/platform.yml (100%) rename {platforms => eosim/platforms}/nuclear-sim/platform.yml (100%) rename {platforms => eosim/platforms}/nuvoton-m480/platform.yml (100%) rename {platforms => eosim/platforms}/nxp-s32g/platform.yml (100%) rename {platforms => eosim/platforms}/omap-l138/platform.yml (100%) rename {platforms => eosim/platforms}/onsemi-rsl10/platform.yml (100%) rename {platforms => eosim/platforms}/physiology-sim/platform.yml (100%) rename {platforms => eosim/platforms}/physiology-sim/tests.yml (100%) rename {platforms => eosim/platforms}/pic18f/platform.yml (100%) rename {platforms => eosim/platforms}/pic32mx/platform.yml (100%) rename {platforms => eosim/platforms}/pic32mz/platform.yml (100%) rename {platforms => eosim/platforms}/ppce500/platform.yml (100%) rename {platforms => eosim/platforms}/ppce500/tests.yml (100%) rename {platforms => eosim/platforms}/ps5-embedded/platform.yml (100%) rename {platforms => eosim/platforms}/psoc6/platform.yml (100%) rename {platforms => eosim/platforms}/qemu-q35/platform.yml (100%) rename {platforms => eosim/platforms}/qemu-q35/tests.yml (100%) rename {platforms => eosim/platforms}/qualcomm-qcs610/platform.yml (100%) rename {platforms => eosim/platforms}/ra4m1/platform.yml (100%) rename {platforms => eosim/platforms}/railway-sim/platform.yml (100%) rename {platforms => eosim/platforms}/raspi-zero2w/platform.yml (100%) rename {platforms => eosim/platforms}/raspi-zero2w/tests.yml (100%) rename {platforms => eosim/platforms}/raspi2b/platform.yml (100%) rename {platforms => eosim/platforms}/raspi2b/tests.yml (100%) rename {platforms => eosim/platforms}/raspi3/platform.yml (100%) rename {platforms => eosim/platforms}/raspi3/tests.yml (100%) rename {platforms => eosim/platforms}/raspi4/platform.yml (100%) rename {platforms => eosim/platforms}/raspi4/tests.yml (100%) rename {platforms => eosim/platforms}/raspi5/platform.yml (100%) rename {platforms => eosim/platforms}/raspi5/tests.yml (100%) rename {platforms => eosim/platforms}/rcar-h3/platform.yml (100%) rename {platforms => eosim/platforms}/rcar-s4/platform.yml (100%) rename {platforms => eosim/platforms}/renesas-ra6m5/platform.yml (100%) rename {platforms => eosim/platforms}/renesas-rcar-s4/platform.yml (100%) rename {platforms => eosim/platforms}/renesas-rh850/platform.yml (100%) rename {platforms => eosim/platforms}/riscv64/eos.yml (100%) rename {platforms => eosim/platforms}/riscv64/platform.yml (100%) rename {platforms => eosim/platforms}/riscv64/tests.yml (100%) rename {platforms => eosim/platforms}/rl78/platform.yml (100%) rename {platforms => eosim/platforms}/roku-tv/platform.yml (100%) rename {platforms => eosim/platforms}/rp2040/platform.yml (100%) rename {platforms => eosim/platforms}/rp2040/tests.yml (100%) rename {platforms => eosim/platforms}/rx65n/platform.yml (100%) rename {platforms => eosim/platforms}/rza2m/platform.yml (100%) rename {platforms => eosim/platforms}/s32g274a/platform.yml (100%) rename {platforms => eosim/platforms}/s32k344/platform.yml (100%) rename {platforms => eosim/platforms}/s32z/platform.yml (100%) rename {platforms => eosim/platforms}/samc21/platform.yml (100%) rename {platforms => eosim/platforms}/samd21/platform.yml (100%) rename {platforms => eosim/platforms}/samd51/platform.yml (100%) rename {platforms => eosim/platforms}/samd51/tests.yml (100%) rename {platforms => eosim/platforms}/same70/platform.yml (100%) rename {platforms => eosim/platforms}/saml21/platform.yml (100%) rename {platforms => eosim/platforms}/samsung-exynos-auto-v9/platform.yml (100%) rename {platforms => eosim/platforms}/si7021/platform.yml (100%) rename {platforms => eosim/platforms}/sifive_u/platform.yml (100%) rename {platforms => eosim/platforms}/sifive_u/tests.yml (100%) rename {platforms => eosim/platforms}/smart-city-sim/platform.yml (100%) rename {platforms => eosim/platforms}/starfive-jh7110/platform.yml (100%) rename {platforms => eosim/platforms}/steamdeck-embedded/platform.yml (100%) rename {platforms => eosim/platforms}/stm32f4/platform.yml (100%) rename {platforms => eosim/platforms}/stm32f4/tests.yml (100%) rename {platforms => eosim/platforms}/stm32h7/platform.yml (100%) rename {platforms => eosim/platforms}/stm32h7/tests.yml (100%) rename {platforms => eosim/platforms}/stm32l4/platform.yml (100%) rename {platforms => eosim/platforms}/stm32l4/tests.yml (100%) rename {platforms => eosim/platforms}/stm32mp1/platform.yml (100%) rename {platforms => eosim/platforms}/stm32mp1/tests.yml (100%) rename {platforms => eosim/platforms}/switch-embedded/platform.yml (100%) rename {platforms => eosim/platforms}/tda4vm/platform.yml (100%) rename {platforms => eosim/platforms}/templates/arm64-board.yml (100%) rename {platforms => eosim/platforms}/templates/mcu-board.yml (100%) rename {platforms => eosim/platforms}/ti-msp432/platform.yml (100%) rename {platforms => eosim/platforms}/ti-tda4vm/platform.yml (100%) rename {platforms => eosim/platforms}/ti-tms570/platform.yml (100%) rename {platforms => eosim/platforms}/tizen-tv/platform.yml (100%) rename {platforms => eosim/platforms}/tms320/platform.yml (100%) rename {platforms => eosim/platforms}/ubiquiti-edgerouter/platform.yml (100%) rename {platforms => eosim/platforms}/versatilepb/platform.yml (100%) rename {platforms => eosim/platforms}/versatilepb/tests.yml (100%) rename {platforms => eosim/platforms}/vexpress-a15/platform.yml (100%) rename {platforms => eosim/platforms}/vexpress-a15/tests.yml (100%) rename {platforms => eosim/platforms}/vexpress-a9/platform.yml (100%) rename {platforms => eosim/platforms}/vexpress-a9/tests.yml (100%) rename {platforms => eosim/platforms}/weather-sim/platform.yml (100%) rename {platforms => eosim/platforms}/weather-sim/tests.yml (100%) rename {platforms => eosim/platforms}/webos-tv/platform.yml (100%) rename {platforms => eosim/platforms}/x86_64/platform.yml (100%) rename {platforms => eosim/platforms}/x86_64/tests.yml (100%) rename {platforms => eosim/platforms}/xilinx-versal-auto/platform.yml (100%) rename {platforms => eosim/platforms}/xilinx-versal/platform.yml (100%) rename {platforms => eosim/platforms}/xilinx-zynq7020/platform.yml (100%) rename {platforms => eosim/platforms}/xtensa-esp/platform.yml (100%) rename {platforms => eosim/platforms}/xtensa-esp/tests.yml (100%) create mode 100644 out/logs/stm32f4.log create mode 100644 tests/unit/test_native_engine_execution.py diff --git a/.coverage b/.coverage index f976fd1c02da8abf829a2dfac95401164dea147d..39c3cbcef55602fb9dedfdcbb02c231dfef2ba64 100644 GIT binary patch literal 548864 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

PMH7@jr};~0YJ1Ws@d44Fj8nV^ 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lk6tgI=QrfeXAtO9B@cX*FIH(0kBoyeOp6 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 O79av%)xf>>2R{O3)vm<= literal 77824 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

y?TmEfmyxf2B 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 L5;L-Kz2g4^I;ShW diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8f8455..47296ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,7 +137,7 @@ jobs: " - name: Platform count check run: | - count=$(ls -d platforms/*/platform.yml 2>/dev/null | wc -l) + count=$(ls -d eosim/platforms/*/platform.yml 2>/dev/null | wc -l) echo "Platform count: $count" if [ "$count" -lt 100 ]; then echo "::warning::Platform count ($count) is below 100" diff --git a/eosim.egg-info/PKG-INFO b/eosim.egg-info/PKG-INFO index 1e160ab..1ccbf86 100644 --- a/eosim.egg-info/PKG-INFO +++ b/eosim.egg-info/PKG-INFO @@ -1,16 +1,16 @@ Metadata-Version: 2.4 Name: eosim Version: 3.0.1 -Summary: World-class multi-architecture embedded simulation platform — 150+ platforms, 40 domains, 26 architectures +Summary: Multi-architecture embedded simulation platform for EoS — native engine plus Renode/QEMU backends Author-email: EoS Project Maintainer-email: EoS Project License: MIT -Project-URL: Homepage, https://github.com/embeddedos-org/eosim -Project-URL: Repository, https://github.com/embeddedos-org/eosim -Project-URL: Documentation, https://github.com/embeddedos-org/eosim/tree/main/docs -Project-URL: Bug Tracker, https://github.com/embeddedos-org/eosim/issues -Project-URL: Changelog, https://github.com/embeddedos-org/eosim/blob/main/CHANGELOG.md -Classifier: Development Status :: 5 - Production/Stable +Project-URL: Homepage, https://github.com/embeddedos-org/EoSim +Project-URL: Repository, https://github.com/embeddedos-org/EoSim +Project-URL: Documentation, https://docs.eosim.io +Project-URL: Bug Tracker, https://github.com/embeddedos-org/EoSim/issues +Project-URL: Changelog, https://github.com/embeddedos-org/EoSim/blob/master/CHANGELOG.md +Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: MIT License diff --git a/eosim.egg-info/SOURCES.txt b/eosim.egg-info/SOURCES.txt index db3bd80..5ae3a8d 100644 --- a/eosim.egg-info/SOURCES.txt +++ b/eosim.egg-info/SOURCES.txt @@ -27,6 +27,8 @@ eosim/cli/__init__.py eosim/cli/main.py eosim/codegen/__init__.py eosim/codegen/generator.py +eosim/config/__init__.py +eosim/config/production.py eosim/core/__init__.py eosim/core/cluster.py eosim/core/domains.py @@ -173,6 +175,8 @@ eosim/gui/widgets/memory_view.py eosim/gui/widgets/peripheral_panel.py eosim/gui/widgets/uart_terminal.py eosim/gui/widgets/viewer_3d.py +eosim/i18n/__init__.py +eosim/i18n/translator.py eosim/integrations/__init__.py eosim/integrations/airsim.py eosim/integrations/carla.py @@ -192,6 +196,205 @@ eosim/integrations/xplane.py eosim/network/__init__.py eosim/network/topology.py eosim/platforms/__init__.py +eosim/platforms/adi-aducm4050/platform.yml +eosim/platforms/aerodynamics-sim/platform.yml +eosim/platforms/aerodynamics-sim/tests.yml +eosim/platforms/agriculture-sim/platform.yml +eosim/platforms/allwinner-d1/platform.yml +eosim/platforms/am62x/platform.yml +eosim/platforms/am64x/platform.yml +eosim/platforms/am64x/tests.yml +eosim/platforms/ambiq-apollo4/platform.yml +eosim/platforms/android-arm64/platform.yml +eosim/platforms/android-tv/platform.yml +eosim/platforms/android-x86/platform.yml +eosim/platforms/apple-m1/platform.yml +eosim/platforms/apple-tv/platform.yml +eosim/platforms/arc-em/platform.yml +eosim/platforms/arc-em/tests.yml +eosim/platforms/arm-ethos-u55/platform.yml +eosim/platforms/arm-mcu/nrf52.yml +eosim/platforms/arm-mcu/platform.yml +eosim/platforms/arm-mcu/rp2040.yml +eosim/platforms/arm-mcu/tests.yml +eosim/platforms/arm-vexpress/platform.yml +eosim/platforms/arm64/eos.yml +eosim/platforms/arm64/platform.yml +eosim/platforms/arm64/tests.yml +eosim/platforms/atmega2560/platform.yml +eosim/platforms/atmega328p/platform.yml +eosim/platforms/attiny85/platform.yml +eosim/platforms/aurix-tc3xx/platform.yml +eosim/platforms/aurix-tc3xx/tests.yml +eosim/platforms/beaglebone/platform.yml +eosim/platforms/beaglebone/tests.yml +eosim/platforms/bl602/platform.yml +eosim/platforms/bl706/platform.yml +eosim/platforms/canaan-k510/platform.yml +eosim/platforms/cc2652/platform.yml +eosim/platforms/cc3220/platform.yml +eosim/platforms/ch32v307/platform.yml +eosim/platforms/cisco-catalyst-embedded/platform.yml +eosim/platforms/construction-sim/platform.yml +eosim/platforms/cortex-r5/platform.yml +eosim/platforms/cortex-r5/tests.yml +eosim/platforms/cortex-r52/platform.yml +eosim/platforms/cortex-r52/tests.yml +eosim/platforms/dialog-da14695/platform.yml +eosim/platforms/efm32gg/platform.yml +eosim/platforms/efr32bg22/platform.yml +eosim/platforms/efr32mg24/platform.yml +eosim/platforms/ericsson-baseband/platform.yml +eosim/platforms/esp32/platform.yml +eosim/platforms/esp32/tests.yml +eosim/platforms/esp32c3/platform.yml +eosim/platforms/esp32c3/tests.yml +eosim/platforms/esp32s3/platform.yml +eosim/platforms/esp32s3/tests.yml +eosim/platforms/finance-sim/platform.yml +eosim/platforms/finance-sim/tests.yml +eosim/platforms/fire-tv/platform.yml +eosim/platforms/gaming-sim/platform.yml +eosim/platforms/gaming-sim/tests.yml +eosim/platforms/gd32f303/platform.yml +eosim/platforms/gd32vf103/platform.yml +eosim/platforms/gd32vf103/tests.yml +eosim/platforms/google-coral/platform.yml +eosim/platforms/gowin-gw1n/platform.yml +eosim/platforms/hailo-8/platform.yml +eosim/platforms/holtek-ht32/platform.yml +eosim/platforms/huawei-kunpeng/platform.yml +eosim/platforms/imx8m/platform.yml +eosim/platforms/imx8m/tests.yml +eosim/platforms/imxrt1060/platform.yml +eosim/platforms/infineon-tc4xx/platform.yml +eosim/platforms/intel-cyclone-v/platform.yml +eosim/platforms/intel-movidius/platform.yml +eosim/platforms/ios-arm64/platform.yml +eosim/platforms/jetson-nano/platform.yml +eosim/platforms/jetson-nano/tests.yml +eosim/platforms/jetson-orin/platform.yml +eosim/platforms/jetson-orin/tests.yml +eosim/platforms/k64f/platform.yml +eosim/platforms/k64f/tests.yml +eosim/platforms/kendryte-k210/platform.yml +eosim/platforms/kendryte-k210/tests.yml +eosim/platforms/kneron-kl520/platform.yml +eosim/platforms/lattice-ecp5/platform.yml +eosim/platforms/lattice-ice40/platform.yml +eosim/platforms/logistics-sim/platform.yml +eosim/platforms/lpc4088/platform.yml +eosim/platforms/lpc55s69/platform.yml +eosim/platforms/maritime-sim/platform.yml +eosim/platforms/max32660/platform.yml +eosim/platforms/mcxn947/platform.yml +eosim/platforms/mediatek-mt7688/platform.yml +eosim/platforms/microblaze/platform.yml +eosim/platforms/microblaze/tests.yml +eosim/platforms/mikrotik-routerboard/platform.yml +eosim/platforms/milkv-duo/platform.yml +eosim/platforms/mining-sim/platform.yml +eosim/platforms/mipsel/platform.yml +eosim/platforms/msp430/platform.yml +eosim/platforms/mythic-amp/platform.yml +eosim/platforms/nokia-fpga-radio/platform.yml +eosim/platforms/nrf52/platform.yml +eosim/platforms/nrf52/tests.yml +eosim/platforms/nrf5340/platform.yml +eosim/platforms/nrf9160/platform.yml +eosim/platforms/nuclear-sim/platform.yml +eosim/platforms/nuvoton-m480/platform.yml +eosim/platforms/nxp-s32g/platform.yml +eosim/platforms/omap-l138/platform.yml +eosim/platforms/onsemi-rsl10/platform.yml +eosim/platforms/physiology-sim/platform.yml +eosim/platforms/physiology-sim/tests.yml +eosim/platforms/pic18f/platform.yml +eosim/platforms/pic32mx/platform.yml +eosim/platforms/pic32mz/platform.yml +eosim/platforms/ppce500/platform.yml +eosim/platforms/ppce500/tests.yml +eosim/platforms/ps5-embedded/platform.yml +eosim/platforms/psoc6/platform.yml +eosim/platforms/qemu-q35/platform.yml +eosim/platforms/qemu-q35/tests.yml +eosim/platforms/qualcomm-qcs610/platform.yml +eosim/platforms/ra4m1/platform.yml +eosim/platforms/railway-sim/platform.yml +eosim/platforms/raspi-zero2w/platform.yml +eosim/platforms/raspi-zero2w/tests.yml +eosim/platforms/raspi2b/platform.yml +eosim/platforms/raspi2b/tests.yml +eosim/platforms/raspi3/platform.yml +eosim/platforms/raspi3/tests.yml +eosim/platforms/raspi4/platform.yml +eosim/platforms/raspi4/tests.yml +eosim/platforms/raspi5/platform.yml +eosim/platforms/raspi5/tests.yml +eosim/platforms/rcar-h3/platform.yml +eosim/platforms/rcar-s4/platform.yml +eosim/platforms/renesas-ra6m5/platform.yml +eosim/platforms/renesas-rcar-s4/platform.yml +eosim/platforms/renesas-rh850/platform.yml +eosim/platforms/riscv64/eos.yml +eosim/platforms/riscv64/platform.yml +eosim/platforms/riscv64/tests.yml +eosim/platforms/rl78/platform.yml +eosim/platforms/roku-tv/platform.yml +eosim/platforms/rp2040/platform.yml +eosim/platforms/rp2040/tests.yml +eosim/platforms/rx65n/platform.yml +eosim/platforms/rza2m/platform.yml +eosim/platforms/s32g274a/platform.yml +eosim/platforms/s32k344/platform.yml +eosim/platforms/s32z/platform.yml +eosim/platforms/samc21/platform.yml +eosim/platforms/samd21/platform.yml +eosim/platforms/samd51/platform.yml +eosim/platforms/samd51/tests.yml +eosim/platforms/same70/platform.yml +eosim/platforms/saml21/platform.yml +eosim/platforms/samsung-exynos-auto-v9/platform.yml +eosim/platforms/si7021/platform.yml +eosim/platforms/sifive_u/platform.yml +eosim/platforms/sifive_u/tests.yml +eosim/platforms/smart-city-sim/platform.yml +eosim/platforms/starfive-jh7110/platform.yml +eosim/platforms/steamdeck-embedded/platform.yml +eosim/platforms/stm32f4/platform.yml +eosim/platforms/stm32f4/tests.yml +eosim/platforms/stm32h7/platform.yml +eosim/platforms/stm32h7/tests.yml +eosim/platforms/stm32l4/platform.yml +eosim/platforms/stm32l4/tests.yml +eosim/platforms/stm32mp1/platform.yml +eosim/platforms/stm32mp1/tests.yml +eosim/platforms/switch-embedded/platform.yml +eosim/platforms/tda4vm/platform.yml +eosim/platforms/templates/arm64-board.yml +eosim/platforms/templates/mcu-board.yml +eosim/platforms/ti-msp432/platform.yml +eosim/platforms/ti-tda4vm/platform.yml +eosim/platforms/ti-tms570/platform.yml +eosim/platforms/tizen-tv/platform.yml +eosim/platforms/tms320/platform.yml +eosim/platforms/ubiquiti-edgerouter/platform.yml +eosim/platforms/versatilepb/platform.yml +eosim/platforms/versatilepb/tests.yml +eosim/platforms/vexpress-a15/platform.yml +eosim/platforms/vexpress-a15/tests.yml +eosim/platforms/vexpress-a9/platform.yml +eosim/platforms/vexpress-a9/tests.yml +eosim/platforms/weather-sim/platform.yml +eosim/platforms/weather-sim/tests.yml +eosim/platforms/webos-tv/platform.yml +eosim/platforms/x86_64/platform.yml +eosim/platforms/x86_64/tests.yml +eosim/platforms/xilinx-versal/platform.yml +eosim/platforms/xilinx-versal-auto/platform.yml +eosim/platforms/xilinx-zynq7020/platform.yml +eosim/platforms/xtensa-esp/platform.yml +eosim/platforms/xtensa-esp/tests.yml eosim/plugins/__init__.py eosim/plugins/base.py eosim/plugins/loader.py diff --git a/eosim/cli/main.py b/eosim/cli/main.py index 657aaf9..3898509 100644 --- a/eosim/cli/main.py +++ b/eosim/cli/main.py @@ -1,954 +1,993 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2026 EoS Project -"""EoSim CLI - primary entry point.""" -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path - -import click -import yaml - -EOSIM_ROOT = Path(__file__).parent.parent.parent -PLATFORMS_DIR = EOSIM_ROOT / "platforms" - - -def _find_platform(name): - """Find platform by name across all subdirs, falling back to directory name.""" - for sub in PLATFORMS_DIR.iterdir(): - for yml in sub.glob("*.yml"): - try: - with open(yml) as f: - data = yaml.safe_load(f) - if data and data.get("name") == name: - return yml, data - except Exception: - pass - # Fallback: match by directory name - candidate = PLATFORMS_DIR / name / "platform.yml" - if candidate.exists(): - try: - with open(candidate) as f: - data = yaml.safe_load(f) - if data: - return candidate, data - except Exception: - pass - return None, None - - -def _load_registry(): - """Load the full platform registry.""" - from eosim.core.registry import PlatformRegistry - return PlatformRegistry(str(PLATFORMS_DIR)) - - -@click.group() -@click.version_option(version="2.0.0", prog_name="eosim") -def cli(): - """EoSim - World-class embedded simulation platform (150+ platforms, 40 domains).""" - pass - - -@cli.command("list") -@click.option("--arch", default="", help="Filter by architecture") -@click.option("--vendor", default="", help="Filter by vendor") -@click.option("--class", "platform_class", default="", help="Filter by platform class") -@click.option("--engine", default="", help="Filter by engine") -@click.option("--domain", default="", help="Filter by domain") -@click.option("--group-by", "group_by_field", default="", - help="Group results by field (arch, vendor, class, engine, domain)") -@click.option("--format", "fmt", default="table", - type=click.Choice(["table", "json", "csv"]), help="Output format") -def list_platforms(arch, vendor, platform_class, engine, domain, group_by_field, fmt): - """List available simulation platforms.""" - reg = _load_registry() - platforms = reg.filter( - arch=arch, vendor=vendor, platform_class=platform_class, - engine=engine, domain=domain, - ) - - if group_by_field: - field_map = {"class": "platform_class"} - actual_field = field_map.get(group_by_field, group_by_field) - groups = {} - for p in platforms: - key = getattr(p, actual_field, "") or "(unset)" - groups.setdefault(key, []).append(p) - for group_name in sorted(groups.keys()): - click.echo("\n[%s: %s] (%d platforms)" % ( - group_by_field, group_name, len(groups[group_name]))) - _print_platforms(groups[group_name]) - return - - if fmt == "json": - data = [{"name": p.name, "arch": p.arch, "engine": p.engine, - "vendor": p.vendor, "class": p.platform_class, - "soc": p.soc, "domain": p.domain} for p in platforms] - click.echo(json.dumps(data, indent=2)) - elif fmt == "csv": - click.echo("name,arch,engine,vendor,class,soc,domain") - for p in platforms: - click.echo(f"{p.name},{p.arch},{p.engine},{p.vendor},{p.platform_class},{p.soc},{p.domain}") - else: - click.echo("Available platforms (%d):\n" % len(platforms)) - _print_platforms(platforms) - - -def _print_platforms(platforms): - """Print platform list in table format.""" - click.echo(" %-25s %-10s %-10s %-12s %-10s %s" % ( - "NAME", "ARCH", "ENGINE", "VENDOR", "CLASS", "DESCRIPTION")) - click.echo(" " + "-" * 90) - for p in sorted(platforms, key=lambda x: x.name): - click.echo(" %-25s %-10s %-10s %-12s %-10s %s" % ( - p.name, p.arch, p.engine, - p.vendor or "-", p.platform_class or "-", - p.display_name or "")) - - -@cli.command() -@click.argument("query") -def search(query): - """Search platforms by name, vendor, SoC, or architecture.""" - reg = _load_registry() - results = reg.search(query) - if not results: - click.echo(f"No platforms matching: {query}") - return - click.echo("Search results for '%s' (%d matches):\n" % (query, len(results))) - for p in results: - click.echo(" %-25s %-10s %-10s %-12s %s" % ( - p.name, p.arch, p.engine, p.vendor or "-", p.soc or "-")) - - -@cli.command() -def stats(): - """Show platform registry statistics.""" - reg = _load_registry() - st = reg.stats() - click.echo("EoSim Platform Statistics (%d platforms)\n" % reg.count()) - for category, counts in st.items(): - display_name = {"platform_class": "class"}.get(category, category) - click.echo(f" {display_name}:") - for value, count in counts.items(): - click.echo(" %-20s %d" % (value, count)) - click.echo() - - -@cli.command() -@click.argument("platform") -def info(platform): - """Show detailed platform information.""" - cfg_path, data = _find_platform(platform) - if not data: - click.echo("Platform not found: " + platform, err=True) - sys.exit(1) - print(yaml.dump(data, default_flow_style=False)) - - -@cli.command() -@click.argument("platform") -@click.option("--headless/--interactive", default=True, help="Run headless (default) or interactive") -@click.option("--timeout", default=60, help="Timeout in seconds") -@click.option("--log-dir", default="out/logs", help="Log output directory") -def run(platform, headless, timeout, log_dir): - """Run a simulation for the specified platform.""" - cfg_path, p = _find_platform(platform) - if not p: - click.echo("Platform not found: " + platform, err=True) - sys.exit(1) - - engine = p.get("engine", "renode") - arch = p.get("arch", "unknown") - click.echo(f"EoSim: launching {platform} ({arch}) via {engine}") - - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, platform + ".log") - - if engine == "renode": - _run_renode(p, platform, headless, timeout, log_file) - elif engine == "qemu": - _run_qemu(p, platform, headless, timeout, log_file) - elif engine == "eosim": - _run_eosim(p, platform, headless, timeout, log_file) - else: - click.echo("Unknown engine: " + engine, err=True) - sys.exit(1) - - -def _run_renode(p, platform, headless, timeout, log_file): - """Run using the Renode engine.""" - renode = shutil.which("renode") - if not renode: - click.echo("Renode not found. Install: https://renode.io") - click.echo("Falling back to EoSim native engine...") - _run_eosim(p, platform, headless, timeout, log_file) - return - resc = PLATFORMS_DIR / platform / p.get("resc", "sim.resc") - cmd = [renode, "--disable-xwt", "--plain", str(resc)] - if headless: - cmd.append("--hide-log") - click.echo("Running: " + " ".join(cmd)) - try: - result = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True) - with open(log_file, "w") as f: - f.write(result.stdout) - f.write(result.stderr) - click.echo("Log: " + log_file) - if result.returncode == 0: - click.echo("PASSED") - else: - click.echo("FAILED (exit %d)" % result.returncode) - sys.exit(1) - except subprocess.TimeoutExpired: - click.echo("Timeout after %ds — saving log" % timeout) - except FileNotFoundError: - click.echo("Engine not found, falling back to EoSim native engine") - _run_eosim(p, platform, headless, timeout, log_file) - - -def _run_qemu(p, platform, headless, timeout, log_file): - """Run using the QEMU engine.""" - arch = p.get("arch", "x86_64") - qemu_map = { - "arm64": "qemu-system-aarch64", - "aarch64": "qemu-system-aarch64", - "arm": "qemu-system-arm", - "riscv64": "qemu-system-riscv64", - "x86_64": "qemu-system-x86_64", - "mipsel": "qemu-system-mipsel", - } - qemu = shutil.which(qemu_map.get(arch, "qemu-system-" + arch)) - if not qemu: - click.echo(f"QEMU not found for {arch} — simulation skipped") - click.echo(f"Install: sudo apt install qemu-system-{arch}") - with open(log_file, "w") as f: - f.write(f"QEMU not available for {arch}\nPASSED (dry run)\n") - click.echo("PASSED (dry run)") - return - machine = p.get("qemu", {}).get("machine", "virt") - cpu = p.get("qemu", {}).get("cpu", "") - memory = p.get("runtime", {}).get("memory_mb", 512) - cmd = [qemu, "-machine", machine, "-m", str(memory), "-nographic", "-no-reboot"] - if cpu: - cmd += ["-cpu", cpu] - click.echo("Running: " + " ".join(cmd)) - click.echo("PASSED (QEMU fallback)") - - -def _run_eosim(p, platform, headless, timeout, log_file): - """Run using the EoSim native engine.""" - from eosim.engine.native import VirtualMachine - arch = p.get("arch", "arm") - memory = p.get("runtime", {}).get("memory_mb", 128) - click.echo("EoSim native engine: %s (%s, %dMB)" % (platform, arch, memory)) - vm = VirtualMachine(name=platform, arch=arch, ram_mb=min(memory, 64)) - result = vm.run(max_cycles=10000, timeout_s=float(timeout)) - with open(log_file, "w") as f: - f.write("=== EoSim Native Log ===\n") - f.write(f"Platform: {platform}\nArch: {arch}\n\n") - f.write(result.get("boot_log", "")) - click.echo("Log: " + log_file) - if result.get("success"): - click.echo("PASSED (%d cycles)" % result.get("cycles", 0)) - else: - click.echo("FAILED") - sys.exit(1) - - -@cli.command() -@click.argument("platform") -@click.option("--timeout", default=60, help="Boot timeout") -@click.option("--junit/--no-junit", default=False, help="Output JUnit XML") -def test(platform, timeout, junit): - """Run validation tests for a platform.""" - cfg_path, p = _find_platform(platform) - if not p: - click.echo("Platform not found: " + platform, err=True) - sys.exit(1) - test_cfg = cfg_path.parent / "tests.yml" if cfg_path else None - checks = [] - if test_cfg and test_cfg.exists(): - with open(test_cfg) as f: - t = yaml.safe_load(f) - checks = t.get("checks", []) - click.echo("EoSim test: %s (%d checks)" % (platform, len(checks))) - passed = 0 - for c in checks: - ctype = c.get("type", "") - click.echo(" [CHECK] {}: {}".format(ctype, c.get("value", c.get("seconds", "")))) - passed += 1 - click.echo("Result: %d/%d passed" % (passed, len(checks))) - - -@cli.command() -@click.argument("platform_config", required=False, type=click.Path()) -@click.option("--all", "validate_all", is_flag=True, help="Validate all platform configs") -def validate(platform_config, validate_all): - """Validate a platform configuration file.""" - from eosim.core.schema import validate_platform - - if validate_all: - passed = 0 - failed = 0 - for sub in sorted(PLATFORMS_DIR.iterdir()): - cfg = sub / "platform.yml" - if not cfg.exists() or sub.name == "templates": - continue - with open(cfg) as f: - p = yaml.safe_load(f) - errors = validate_platform(p) - if errors: - failed += 1 - click.echo(f"FAILED: {sub.name}") - for e in errors: - click.echo(" ERROR: " + e) - else: - passed += 1 - click.echo("Validated: %d passed, %d failed" % (passed, failed)) - if failed > 0: - sys.exit(1) - return - - if not platform_config: - click.echo("Error: provide a platform config file or use --all", err=True) - sys.exit(1) - - if not os.path.exists(platform_config): - click.echo("File not found: " + platform_config, err=True) - sys.exit(1) - - with open(platform_config) as f: - p = yaml.safe_load(f) - errors = validate_platform(p) - if errors: - for e in errors: - click.echo("ERROR: " + e) - sys.exit(1) - click.echo("Valid: " + platform_config) - - -@cli.command("simulate") -@click.option("--platform", required=True, help="Platform to simulate") -@click.option("--duration", default=60, help="Simulation duration in seconds") -@click.option("--headless/--interactive", default=True, help="Run headless (default)") -@click.option("--nested-install", is_flag=True, help="Test EoSim install inside guest") -def simulate(platform, duration, headless, nested_install): - """Run a simulation for the specified platform (alias for 'run').""" - cfg_path, p = _find_platform(platform) - if not p: - click.echo("Platform not found: " + platform, err=True) - sys.exit(1) - - engine = p.get("engine", "renode") - arch = p.get("arch", "unknown") - click.echo(f"EoSim: simulating {platform} ({arch}) via {engine}") - - log_dir = "out/logs" - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, platform + ".log") - - if engine == "renode": - _run_renode(p, platform, headless, duration, log_file) - elif engine == "qemu": - _run_qemu(p, platform, headless, duration, log_file) - elif engine == "eosim": - _run_eosim(p, platform, headless, duration, log_file) - else: - click.echo("Unknown engine: " + engine, err=True) - sys.exit(1) - - if nested_install: - click.echo(f"Nested install test: simulated for {platform}") - - -@cli.command("list-platforms") -def list_platforms_alias(): - """List available simulation platforms (alias).""" - reg = _load_registry() - platforms = reg.all() - click.echo("Available platforms (%d):\n" % len(platforms)) - _print_platforms(platforms) - - -@cli.command() -@click.argument("platform") -@click.option("--output", default="out/artifacts", help="Output directory") -def artifact(platform, output): - """Export simulation artifacts.""" - os.makedirs(output, exist_ok=True) - manifest = { - "platform": platform, - "version": "2.0.0", - "artifacts": ["logs", "traces", "reports"], - } - manifest_path = os.path.join(output, platform + "-manifest.json") - with open(manifest_path, "w") as f: - json.dump(manifest, f, indent=2) - click.echo("Artifacts exported to: " + output) - - -@cli.command() -def doctor(): - """Check EoSim environment health.""" - from eosim.core.host import HostEnvironment - env = HostEnvironment.detect() - - click.echo("EoSim Doctor\n") - click.echo("Host Environment:") - info = env.platform_info() - for key, val in info.items(): - click.echo(" %-20s %s" % (key, val)) - click.echo() - - click.echo("Simulation Engines:") - checks = [ - ("EoSim native", "built-in", False), - ("EoSim (x86_64)", "available", False), - ("EoSim (aarch64)", "available", False), - ("EoSim (arm)", "available", False), - ("EoSim (riscv64)", "available", False), - ("Renode", env.resolve_renode(), False), - ] - for name, path, required in checks: - if path: - click.echo(" %-20s OK (%s)" % (name, path)) - else: - status = "MISSING" if required else "not found (optional)" - click.echo(" %-20s %s" % (name, status)) - - click.echo("\nPython Packages:") - for pkg in ["click", "pyyaml", "pytest"]: - try: - __import__(pkg.replace("-", "_")) - click.echo(" %-20s installed" % pkg) - except ImportError: - click.echo(" %-20s MISSING" % pkg) - - click.echo("\nPlatform Registry:") - reg = _load_registry() - click.echo(" %-20s %d" % ("Total platforms", reg.count())) - click.echo(" %-20s %s" % ("Vendors", ", ".join(reg.vendors()) or "none")) - click.echo(" %-20s %s" % ("Architectures", ", ".join(reg.arches()) or "none")) - - -# --- Domain subcommands --- - -@cli.group() -def domain(): - """Simulation domain categories and profiles.""" - pass - - -@domain.command("list") -def domain_list(): - """List all simulation domain categories.""" - from eosim.core.domains import DOMAIN_CATALOG - click.echo("Simulation Domains (%d):\n" % len(DOMAIN_CATALOG)) - for name, profile in sorted(DOMAIN_CATALOG.items()): - click.echo(" %-15s %s" % (name, profile.display_name)) - click.echo(" %-15s %s" % ("", profile.description)) - click.echo() - - -@domain.command("info") -@click.argument("name") -def domain_info(name): - """Show detailed domain profile.""" - from eosim.core.domains import get_domain - d = get_domain(name) - if not d: - click.echo(f"Unknown domain: {name}", err=True) - sys.exit(1) - click.echo(f"Domain: {d.display_name}") - click.echo(f"Description: {d.description}") - if d.safety_levels: - click.echo("Safety Levels: {}".format(", ".join(d.safety_levels))) - click.echo("Standards: {}".format(", ".join(d.standards))) - click.echo("Typical Arches: {}".format(", ".join(d.typical_arches))) - click.echo("Typical Classes: {}".format(", ".join(d.typical_classes))) - if d.test_scenarios: - click.echo("Test Scenarios: {}".format(", ".join(d.test_scenarios))) - - -# --- Modeling subcommands --- - -@cli.group() -def modeling(): - """Simulation modeling methods and parameters.""" - pass - - -@modeling.command("list") -def modeling_list(): - """List all modeling methods.""" - from eosim.core.modeling import MODELING_CATALOG - click.echo("Modeling Methods (%d):\n" % len(MODELING_CATALOG)) - for name, method in sorted(MODELING_CATALOG.items()): - engines = ", ".join(method.engine_support) - click.echo(" %-20s %s (engines: %s)" % (name, method.display_name, engines)) - - -@modeling.command("info") -@click.argument("name") -def modeling_info(name): - """Show detailed modeling method info.""" - from eosim.core.modeling import get_modeling - m = get_modeling(name) - if not m: - click.echo(f"Unknown modeling method: {name}", err=True) - sys.exit(1) - click.echo(f"Method: {m.display_name}") - click.echo(f"Description: {m.description}") - click.echo("Supported Engines: {}".format(", ".join(m.engine_support))) - click.echo("Use Cases: {}".format(", ".join(m.use_cases))) - if m.parameters: - click.echo("Parameters:") - for pname, ptype in m.parameters.items(): - click.echo(" %-20s %s" % (pname, ptype)) - - -# --- EoS integration subcommands --- - -@cli.group() -def eos(): - """EoS integration — build, test, and validate EoS through EoSim.""" - pass - - -@eos.command("find") -def eos_find(): - """Find EoS source code on this system.""" - from eosim.integrations.eos_runner import find_eos_source - src = find_eos_source() - if src: - click.echo("EoS source found: " + src) - else: - click.echo("EoS source not found. Set EOS_SOURCE env var or clone to ./eos") - - -@eos.command("build") -@click.option("--source", default=None, help="EoS source directory") -def eos_build(source): - """Build EoS from source.""" - from eosim.integrations.eos_runner import build_eos, find_eos_source - src = source or find_eos_source() - if not src: - click.echo("EoS source not found", err=True) - sys.exit(1) - click.echo("Building EoS from: " + src) - ok, log = build_eos(src) - if ok: - click.echo("BUILD: PASSED") - else: - click.echo("BUILD: FAILED") - click.echo(log[-500:] if len(log) > 500 else log) - sys.exit(1) - - -@eos.command("test") -@click.option("--source", default=None, help="EoS source directory") -@click.option("--verbose", "-v", is_flag=True, help="Show test output") -def eos_test(source, verbose): - """Build and run all EoS unit tests.""" - from eosim.integrations.eos_runner import find_eos_source, run_eos_tests - src = source or find_eos_source() - if not src: - click.echo("EoS source not found", err=True) - sys.exit(1) - click.echo("EoSim: Building and testing EoS from: " + src) - click.echo("") - suite = run_eos_tests(src) - click.echo(suite.summary()) - if verbose: - for r in suite.results: - if r.output and not r.passed: - click.echo(f"\n--- {r.name} ---") - click.echo(r.output[-300:]) - if suite.failed > 0: - sys.exit(1) - - -@eos.command("test-suite") -@click.option("--source", default=None, help="eApps source directory") -def eos_test_suite(source): - """Run eApps Python tests.""" - from eosim.integrations.eos_runner import run_eosuite_tests - candidates = [ - source, - os.path.join(os.getcwd(), "..", "eApps"), - os.path.expanduser("~/EoS/eApps"), - "C:/Users/spatchava/EoS/eApps", - "/mnt/c/Users/spatchava/EoS/eApps", - ] - src = None - for c in candidates: - if c and os.path.isdir(c) and os.path.exists(os.path.join(c, "tests")): - src = c - break - if not src: - click.echo("eApps source not found", err=True) - sys.exit(1) - click.echo("EoSim: Testing eApps from: " + src) - suite = run_eosuite_tests(src) - click.echo(suite.summary()) - - -@cli.command("ecosystem") -@click.option("--workspace", default=None, help="EoS workspace root") -@click.option("--simulate/--no-simulate", default=True, help="Run simulations") -def ecosystem(workspace, simulate): - """Test ALL EoS repos — build, test, simulate, validate.""" - from eosim.integrations.ecosystem import find_repos, run_ecosystem_tests - click.echo("EoSim Ecosystem Validation") - click.echo("") - repos = find_repos(workspace) - if not repos: - click.echo("No EoS repos found. Set --workspace or EOS_WORKSPACE env var.", err=True) - sys.exit(1) - click.echo("Found %d repos: %s" % (len(repos), ", ".join(repos.keys()))) - click.echo("") - report = run_ecosystem_tests(workspace) - click.echo(report.summary()) - if report.repos_failed > 0: - sys.exit(1) - - -@cli.command() -def gui(): - """Launch the EoSim simulation UI.""" - import tkinter as tk - - from eosim.gui.tk_app import TkSimulatorApp - - root = tk.Tk() - root.title("EoSim — Embedded Simulator") - root.geometry("1200x750") - root.minsize(900, 600) - - app = TkSimulatorApp(root) - app.pack(fill=tk.BOTH, expand=True) - - root.protocol("WM_DELETE_WINDOW", lambda: (app.on_close(), root.destroy())) - root.mainloop() - - -# --- HIL subcommands --- - -@cli.group() -def hil(): - """Hardware-in-the-loop — connect to real development boards.""" - pass - - -@hil.command("detect") -def hil_detect(): - """Detect connected debug probes and serial ports.""" - from eosim.integrations.openocd import OpenOCDManager - from eosim.integrations.serial_bridge import SerialBridge - - click.echo("EoSim HIL — Device Detection\n") - - openocd = OpenOCDManager.find_openocd() - if openocd: - click.echo(f"OpenOCD: {openocd}") - else: - click.echo("OpenOCD: NOT FOUND (install from https://openocd.org/)") - - click.echo("\nSerial Ports:") - if SerialBridge.available(): - ports = SerialBridge.list_ports() - if ports: - for p in ports: - click.echo(" %-15s %s" % (p['device'], p['description'])) - else: - click.echo(" (none found)") - - boards = SerialBridge.detect_dev_boards() - if boards: - click.echo("\nDetected Dev Boards:") - for b in boards: - click.echo(" %-15s %s" % (b['device'], b['board'])) - else: - click.echo(" pyserial not installed — run: pip install pyserial") - - -@hil.command("connect") -@click.option("--adapter", default="stlink", help="Debug adapter (stlink, jlink, cmsis-dap)") -@click.option("--target", default="stm32f4", help="Target MCU (stm32f4, nrf52, etc.)") -@click.option("--serial", "serial_port", default="", help="Serial port (COM3, /dev/ttyUSB0)") -@click.option("--baudrate", default=115200, help="Serial baud rate") -@click.option("--gdb-port", default=3333, help="GDB port") -def hil_connect(adapter, target, serial_port, baudrate, gdb_port): - """Connect to a real development board via OpenOCD.""" - from eosim.integrations.hil_session import HILSession - - click.echo(f"EoSim HIL — Connecting to {target} via {adapter}") - session = HILSession() - try: - session.start( - adapter=adapter, target=target, - serial_port=serial_port, baudrate=baudrate, - gdb_port=gdb_port, - ) - click.echo("Connected! GDB on port %d" % gdb_port) - if serial_port: - click.echo("Serial bridge: %s @ %d baud" % (serial_port, baudrate)) - - state = session.get_state() - click.echo("\nSession state:") - for k, v in state.items(): - click.echo(" %-20s %s" % (k, v)) - - click.echo("\nPress Ctrl+C to disconnect...") - try: - import time - while True: - time.sleep(1) - except KeyboardInterrupt: - pass - except Exception as e: - click.echo(f"Connection failed: {e}", err=True) - sys.exit(1) - finally: - session.stop() - click.echo("Disconnected.") - - -@hil.command("flash") -@click.argument("firmware", type=click.Path(exists=True)) -@click.option("--adapter", default="stlink", help="Debug adapter") -@click.option("--target", default="stm32f4", help="Target MCU") -def hil_flash(firmware, adapter, target): - """Flash firmware to a real target via OpenOCD.""" - from eosim.integrations.openocd import OpenOCDManager - - click.echo(f"EoSim HIL — Flashing {firmware} to {target} via {adapter}") - mgr = OpenOCDManager() - try: - ok = mgr.flash(firmware) - if ok: - click.echo("Flash: PASSED") - else: - click.echo("Flash: FAILED") - sys.exit(1) - except Exception as e: - click.echo(f"Flash error: {e}", err=True) - sys.exit(1) - - -@hil.command("monitor") -@click.option("--adapter", default="stlink", help="Debug adapter") -@click.option("--target", default="stm32f4", help="Target MCU") -@click.option("--gdb-port", default=3333, help="GDB port") -def hil_monitor(adapter, target, gdb_port): - """Live register/memory monitoring (text mode).""" - from eosim.integrations.hil_session import HILSession - - click.echo(f"EoSim HIL Monitor — {target} via {adapter} (Ctrl+C to quit)\n") - session = HILSession() - try: - session.start(adapter=adapter, target=target, gdb_port=gdb_port) - session.halt() - import time - while True: - regs = session.read_registers() - if regs: - click.echo("\033[2J\033[H") - click.echo(f"=== {target} Registers ===") - for name, val in sorted(regs.items()): - click.echo(" %-6s 0x%08X" % (name, val)) - time.sleep(0.5) - except KeyboardInterrupt: - pass - finally: - session.stop() - - -# --- Bridge subcommands (external tool integrations) --- - -@cli.group() -def bridge(): - """External tool bridges — X-Plane, Gazebo, OpenFOAM.""" - pass - - -@bridge.command("status") -def bridge_status(): - """Show status of all external tool bridges.""" - from eosim.engine.backend import ( - AirSimEngine, CARLAEngine, GazeboEngine, OpenFOAMEngine, - ROS2Engine, XPlaneEngine, - ) - click.echo("EoSim Bridge Status\n") - click.echo(" %-15s %s" % ("X-Plane", "available" if XPlaneEngine.available() else "not connected")) - click.echo(" %-15s %s" % ("Gazebo", "available" if GazeboEngine.available() else "not installed")) - click.echo(" %-15s %s" % ("OpenFOAM", "available" if OpenFOAMEngine.available() else "not installed")) - click.echo(" %-15s %s" % ("CARLA", "available" if CARLAEngine.available() else "not connected")) - click.echo(" %-15s %s" % ("AirSim", "available" if AirSimEngine.available() else "not connected")) - click.echo(" %-15s %s" % ("ROS 2", "available" if ROS2Engine.available() else "not installed")) - - -@bridge.group("xplane") -def bridge_xplane(): - """X-Plane flight simulator bridge.""" - pass - - -@bridge_xplane.command("connect") -@click.option("--host", default="127.0.0.1", help="X-Plane host") -@click.option("--port", default=49000, help="X-Plane UDP port") -def xplane_connect(host, port): - """Connect to X-Plane simulator.""" - from eosim.integrations.xplane import XPlaneConnection - click.echo("Connecting to X-Plane at %s:%d..." % (host, port)) - conn = XPlaneConnection(host=host, port=port) - if conn.connect(): - click.echo("Connected to X-Plane") - status = conn.get_status() - for k, v in status.items(): - click.echo(" %-20s %s" % (k, v)) - conn.disconnect() - else: - click.echo("Failed to connect to X-Plane", err=True) - sys.exit(1) - - -@bridge.group("gazebo") -def bridge_gazebo(): - """Gazebo simulation bridge.""" - pass - - -@bridge_gazebo.command("connect") -@click.option("--host", default="127.0.0.1", help="Gazebo host") -@click.option("--port", default=11345, help="Gazebo port") -def gazebo_connect(host, port): - """Connect to Gazebo simulator.""" - from eosim.integrations.gazebo import GazeboConnection - click.echo("Connecting to Gazebo at %s:%d..." % (host, port)) - conn = GazeboConnection(host=host, port=port) - if conn.connect(): - click.echo("Connected to Gazebo") - status = conn.get_status() - for k, v in status.items(): - click.echo(" %-20s %s" % (k, v)) - conn.disconnect() - else: - click.echo("Failed to connect to Gazebo", err=True) - sys.exit(1) - - -@bridge.group("openfoam") -def bridge_openfoam(): - """OpenFOAM CFD solver bridge.""" - pass - - -@bridge_openfoam.command("run") -@click.option("--case-dir", required=True, help="OpenFOAM case directory") -@click.option("--solver", default="simpleFoam", help="Solver name") -def openfoam_run(case_dir, solver): - """Run an OpenFOAM simulation.""" - from eosim.integrations.openfoam import OpenFOAMRunner - click.echo(f"Running OpenFOAM solver '{solver}' on case: {case_dir}") - runner = OpenFOAMRunner(case_dir=case_dir) - runner.set_solver(solver) - result = runner.run() - if result['success']: - click.echo("Solver completed successfully") - if result.get('converged'): - click.echo("Solution converged") - else: - click.echo("Solver failed") - click.echo(result.get('log', '')[-500:]) - sys.exit(1) - - -if __name__ == "__main__": - cli() - - -# --- API Server command --- - -@cli.command("api") -@click.option("--host", default="0.0.0.0", help="API server host") -@click.option("--port", default=8080, help="API server port") -def api_server(host, port): - """Start the EoSim REST API server.""" - click.echo(f"EoSim API Server starting on {host}:{port}") - click.echo("Swagger UI: http://%s:%d/docs" % (host if host != "0.0.0.0" else "localhost", port)) - from eosim.api.server import EoSimAPIServer - server = EoSimAPIServer(host=host, port=port) - server.run() - - -# --- Simulators command --- - -@cli.group("simulator") -def simulator_group(): - """Simulator management — list types, products, scenarios.""" - pass - - -@simulator_group.command("list") -def simulator_list(): - """List all available simulator types.""" - from eosim.engine.native.simulators import SimulatorFactory - sims = SimulatorFactory.list_simulators() - click.echo("Available Simulators (%d):\n" % len(sims)) - for s in sims: - click.echo(" " + s) - - -@simulator_group.command("products") -def simulator_products(): - """List all product templates.""" - from eosim.gui.product_templates import PRODUCT_CATALOG - click.echo("Product Templates (%d):\n" % len(PRODUCT_CATALOG)) - click.echo(" %-25s %-25s %-15s %s" % ("NAME", "DISPLAY", "DOMAIN", "SIMULATOR")) - click.echo(" " + "-" * 90) - for name, t in sorted(PRODUCT_CATALOG.items()): - click.echo(" %-25s %-25s %-15s %s" % ( - name, t.display_name, t.domain, t.simulator_class)) - - -@simulator_group.command("run") -@click.argument("product_type") -@click.option("--ticks", default=100, help="Number of simulation ticks") -@click.option("--scenario", default="", help="Load a named scenario") -def simulator_run(product_type, ticks, scenario): - """Run a product simulator interactively.""" - from eosim.engine.native.simulators import SimulatorFactory, SIMULATOR_MAP - if product_type not in SIMULATOR_MAP: - click.echo(f"Unknown product type: {product_type}", err=True) - click.echo("Available: " + ", ".join(sorted(SIMULATOR_MAP.keys())[:20]) + " ...") - sys.exit(1) - - class VM: - peripherals = {} - def add_peripheral(self, name, dev): - self.peripherals[name] = dev - - vm = VM() - sim = SimulatorFactory.create(product_type, vm) - click.echo(f"Simulator: {sim.DISPLAY_NAME} ({sim.PRODUCT_TYPE})") - click.echo(f"Peripherals: {len(vm.peripherals)}") - - if scenario: - sim.load_scenario(scenario) - click.echo(f"Scenario: {scenario}") - - click.echo(f"Running {ticks} ticks...\n") - for i in range(ticks): - sim.tick() - if (i + 1) % (ticks // 5 or 1) == 0: - click.echo(f" Tick {i+1}: {sim.get_status_text()}") - - click.echo(f"\nFinal state:") - for k, v in sim.get_state().items(): - if k != 'scenario': - click.echo(f" {k}: {v}") +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +"""EoSim CLI - primary entry point.""" +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import click +import yaml +from eosim import __version__ + +EOSIM_ROOT = Path(__file__).parent.parent.parent + +def _resolve_platforms_dir() -> Path: + """Locate the platform registry. + + It ships inside the package (eosim/platforms) so a wheel is self-contained. + It previously lived at the repository root and was addressed as + EOSIM_ROOT/"platforms", where EOSIM_ROOT is site-packages once installed - + so every `eosim run` from a wheel died with FileNotFoundError on + site-packages/platforms. An editable install hid this, because there + EOSIM_ROOT is the checkout. + + The repo-root path is still tried, for anyone running an older layout. + """ + packaged = Path(__file__).parent.parent / "platforms" + if packaged.is_dir(): + return packaged + return EOSIM_ROOT / "platforms" + + +PLATFORMS_DIR = _resolve_platforms_dir() + + +def _find_platform(name): + """Find platform by name across all subdirs, falling back to directory name.""" + for sub in PLATFORMS_DIR.iterdir(): + for yml in sub.glob("*.yml"): + try: + with open(yml) as f: + data = yaml.safe_load(f) + if data and data.get("name") == name: + return yml, data + except Exception: + pass + # Fallback: match by directory name + candidate = PLATFORMS_DIR / name / "platform.yml" + if candidate.exists(): + try: + with open(candidate) as f: + data = yaml.safe_load(f) + if data: + return candidate, data + except Exception: + pass + return None, None + + +def _load_registry(): + """Load the full platform registry.""" + from eosim.core.registry import PlatformRegistry + return PlatformRegistry(str(PLATFORMS_DIR)) + + +@click.group() +@click.version_option(version=__version__, prog_name="eosim") +def cli(): + """EoSim - World-class embedded simulation platform (150+ platforms, 40 domains).""" + pass + + +@cli.command("list") +@click.option("--arch", default="", help="Filter by architecture") +@click.option("--vendor", default="", help="Filter by vendor") +@click.option("--class", "platform_class", default="", help="Filter by platform class") +@click.option("--engine", default="", help="Filter by engine") +@click.option("--domain", default="", help="Filter by domain") +@click.option("--group-by", "group_by_field", default="", + help="Group results by field (arch, vendor, class, engine, domain)") +@click.option("--format", "fmt", default="table", + type=click.Choice(["table", "json", "csv"]), help="Output format") +def list_platforms(arch, vendor, platform_class, engine, domain, group_by_field, fmt): + """List available simulation platforms.""" + reg = _load_registry() + platforms = reg.filter( + arch=arch, vendor=vendor, platform_class=platform_class, + engine=engine, domain=domain, + ) + + if group_by_field: + field_map = {"class": "platform_class"} + actual_field = field_map.get(group_by_field, group_by_field) + groups = {} + for p in platforms: + key = getattr(p, actual_field, "") or "(unset)" + groups.setdefault(key, []).append(p) + for group_name in sorted(groups.keys()): + click.echo("\n[%s: %s] (%d platforms)" % ( + group_by_field, group_name, len(groups[group_name]))) + _print_platforms(groups[group_name]) + return + + if fmt == "json": + data = [{"name": p.name, "arch": p.arch, "engine": p.engine, + "vendor": p.vendor, "class": p.platform_class, + "soc": p.soc, "domain": p.domain} for p in platforms] + click.echo(json.dumps(data, indent=2)) + elif fmt == "csv": + click.echo("name,arch,engine,vendor,class,soc,domain") + for p in platforms: + click.echo(f"{p.name},{p.arch},{p.engine},{p.vendor},{p.platform_class},{p.soc},{p.domain}") + else: + click.echo("Available platforms (%d):\n" % len(platforms)) + _print_platforms(platforms) + + +def _print_platforms(platforms): + """Print platform list in table format.""" + click.echo(" %-25s %-10s %-10s %-12s %-10s %s" % ( + "NAME", "ARCH", "ENGINE", "VENDOR", "CLASS", "DESCRIPTION")) + click.echo(" " + "-" * 90) + for p in sorted(platforms, key=lambda x: x.name): + click.echo(" %-25s %-10s %-10s %-12s %-10s %s" % ( + p.name, p.arch, p.engine, + p.vendor or "-", p.platform_class or "-", + p.display_name or "")) + + +@cli.command() +@click.argument("query") +def search(query): + """Search platforms by name, vendor, SoC, or architecture.""" + reg = _load_registry() + results = reg.search(query) + if not results: + click.echo(f"No platforms matching: {query}") + return + click.echo("Search results for '%s' (%d matches):\n" % (query, len(results))) + for p in results: + click.echo(" %-25s %-10s %-10s %-12s %s" % ( + p.name, p.arch, p.engine, p.vendor or "-", p.soc or "-")) + + +@cli.command() +def stats(): + """Show platform registry statistics.""" + reg = _load_registry() + st = reg.stats() + click.echo("EoSim Platform Statistics (%d platforms)\n" % reg.count()) + for category, counts in st.items(): + display_name = {"platform_class": "class"}.get(category, category) + click.echo(f" {display_name}:") + for value, count in counts.items(): + click.echo(" %-20s %d" % (value, count)) + click.echo() + + +@cli.command() +@click.argument("platform") +def info(platform): + """Show detailed platform information.""" + cfg_path, data = _find_platform(platform) + if not data: + click.echo("Platform not found: " + platform, err=True) + sys.exit(1) + print(yaml.dump(data, default_flow_style=False)) + + +@cli.command() +@click.argument("platform") +@click.option("--headless/--interactive", default=True, help="Run headless (default) or interactive") +@click.option("--timeout", default=60, help="Timeout in seconds") +@click.option("--log-dir", default="out/logs", help="Log output directory") +@click.option("--firmware", type=click.Path(exists=True, dir_okay=False), default=None, + help="Firmware image to load and execute (e.g. an EoS build). Without " + "it the native engine has nothing to run.") +def run(platform, headless, timeout, log_dir, firmware): + """Run a simulation for the specified platform.""" + cfg_path, p = _find_platform(platform) + if not p: + click.echo("Platform not found: " + platform, err=True) + sys.exit(1) + + engine = p.get("engine", "renode") + arch = p.get("arch", "unknown") + click.echo(f"EoSim: launching {platform} ({arch}) via {engine}") + + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, platform + ".log") + + if engine == "renode": + _run_renode(p, platform, headless, timeout, log_file, firmware) + elif engine == "qemu": + _run_qemu(p, platform, headless, timeout, log_file, firmware) + elif engine == "eosim": + _run_eosim(p, platform, headless, timeout, log_file, firmware) + else: + click.echo("Unknown engine: " + engine, err=True) + sys.exit(1) + + +def _run_renode(p, platform, headless, timeout, log_file, firmware=None): + """Run using the Renode engine.""" + renode = shutil.which("renode") + if not renode: + click.echo("Renode not found. Install: https://renode.io") + click.echo("Falling back to EoSim native engine...") + _run_eosim(p, platform, headless, timeout, log_file, firmware) + return + resc = PLATFORMS_DIR / platform / p.get("resc", "sim.resc") + cmd = [renode, "--disable-xwt", "--plain", str(resc)] + if headless: + cmd.append("--hide-log") + click.echo("Running: " + " ".join(cmd)) + try: + result = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True) + with open(log_file, "w") as f: + f.write(result.stdout) + f.write(result.stderr) + click.echo("Log: " + log_file) + if result.returncode == 0: + click.echo("PASSED") + else: + click.echo("FAILED (exit %d)" % result.returncode) + sys.exit(1) + except subprocess.TimeoutExpired: + click.echo("Timeout after %ds — saving log" % timeout) + except FileNotFoundError: + click.echo("Engine not found, falling back to EoSim native engine") + _run_eosim(p, platform, headless, timeout, log_file, firmware) + + +def _run_qemu(p, platform, headless, timeout, log_file, firmware=None): + """Run using the QEMU engine.""" + arch = p.get("arch", "x86_64") + qemu_map = { + "arm64": "qemu-system-aarch64", + "aarch64": "qemu-system-aarch64", + "arm": "qemu-system-arm", + "riscv64": "qemu-system-riscv64", + "x86_64": "qemu-system-x86_64", + "mipsel": "qemu-system-mipsel", + } + qemu = shutil.which(qemu_map.get(arch, "qemu-system-" + arch)) + if not qemu: + click.echo(f"QEMU not found for {arch} — simulation skipped") + click.echo(f"Install: sudo apt install qemu-system-{arch}") + with open(log_file, "w") as f: + f.write(f"QEMU not available for {arch}\nPASSED (dry run)\n") + click.echo("PASSED (dry run)") + return + machine = p.get("qemu", {}).get("machine", "virt") + cpu = p.get("qemu", {}).get("cpu", "") + memory = p.get("runtime", {}).get("memory_mb", 512) + cmd = [qemu, "-machine", machine, "-m", str(memory), "-nographic", "-no-reboot"] + if cpu: + cmd += ["-cpu", cpu] + click.echo("Running: " + " ".join(cmd)) + click.echo("PASSED (QEMU fallback)") + + +def _run_eosim(p, platform, headless, timeout, log_file, firmware=None): + """Run using the EoSim native engine.""" + from eosim.engine.native import VirtualMachine + arch = p.get("arch", "arm") + memory = p.get("runtime", {}).get("memory_mb", 128) + click.echo("EoSim native engine: %s (%s, %dMB)" % (platform, arch, memory)) + vm = VirtualMachine(name=platform, arch=arch, ram_mb=min(memory, 64)) + + if firmware: + if not vm.load_firmware(firmware): + click.echo("Could not load firmware: %s" % firmware, err=True) + sys.exit(1) + click.echo("Firmware: %s (%d bytes)" % (firmware, os.path.getsize(firmware))) + + result = vm.run(max_cycles=10000, timeout_s=float(timeout)) + + with open(log_file, "w") as f: + f.write("=== EoSim Native Log ===\n") + f.write("Platform: %s\nArch: %s\n" % (platform, arch)) + f.write("Firmware: %s\n\n" % (firmware or "(none)")) + f.write(result.get("boot_log", "")) + click.echo("Log: " + log_file) + + reason = result.get("reason", "unknown") + if result.get("success"): + click.echo("PASSED (%d cycles, %s)" % (result.get("cycles", 0), reason)) + return + if reason == "no-firmware": + # Previously this path printed "PASSED (10000 cycles)" after stepping + # over zeroed memory with no image loaded. It is not a pass. + click.echo("NO FIRMWARE - nothing was executed. Pass --firmware .", err=True) + sys.exit(2) + click.echo("FAILED (%s after %d cycles)" % (reason, result.get("cycles", 0)), err=True) + sys.exit(1) + + +@cli.command() +@click.argument("platform") +@click.option("--timeout", default=60, help="Boot timeout") +@click.option("--junit/--no-junit", default=False, help="Output JUnit XML") +def test(platform, timeout, junit): + """Run validation tests for a platform.""" + cfg_path, p = _find_platform(platform) + if not p: + click.echo("Platform not found: " + platform, err=True) + sys.exit(1) + test_cfg = cfg_path.parent / "tests.yml" if cfg_path else None + checks = [] + if test_cfg and test_cfg.exists(): + with open(test_cfg) as f: + t = yaml.safe_load(f) + checks = t.get("checks", []) + click.echo("EoSim test: %s (%d checks)" % (platform, len(checks))) + passed = 0 + for c in checks: + ctype = c.get("type", "") + click.echo(" [CHECK] {}: {}".format(ctype, c.get("value", c.get("seconds", "")))) + passed += 1 + click.echo("Result: %d/%d passed" % (passed, len(checks))) + + +@cli.command() +@click.argument("platform_config", required=False, type=click.Path()) +@click.option("--all", "validate_all", is_flag=True, help="Validate all platform configs") +def validate(platform_config, validate_all): + """Validate a platform configuration file.""" + from eosim.core.schema import validate_platform + + if validate_all: + passed = 0 + failed = 0 + for sub in sorted(PLATFORMS_DIR.iterdir()): + cfg = sub / "platform.yml" + if not cfg.exists() or sub.name == "templates": + continue + with open(cfg) as f: + p = yaml.safe_load(f) + errors = validate_platform(p) + if errors: + failed += 1 + click.echo(f"FAILED: {sub.name}") + for e in errors: + click.echo(" ERROR: " + e) + else: + passed += 1 + click.echo("Validated: %d passed, %d failed" % (passed, failed)) + if failed > 0: + sys.exit(1) + return + + if not platform_config: + click.echo("Error: provide a platform config file or use --all", err=True) + sys.exit(1) + + if not os.path.exists(platform_config): + click.echo("File not found: " + platform_config, err=True) + sys.exit(1) + + with open(platform_config) as f: + p = yaml.safe_load(f) + errors = validate_platform(p) + if errors: + for e in errors: + click.echo("ERROR: " + e) + sys.exit(1) + click.echo("Valid: " + platform_config) + + +@cli.command("simulate") +@click.option("--platform", required=True, help="Platform to simulate") +@click.option("--duration", default=60, help="Simulation duration in seconds") +@click.option("--headless/--interactive", default=True, help="Run headless (default)") +@click.option("--nested-install", is_flag=True, help="Test EoSim install inside guest") +def simulate(platform, duration, headless, nested_install): + """Run a simulation for the specified platform (alias for 'run').""" + cfg_path, p = _find_platform(platform) + if not p: + click.echo("Platform not found: " + platform, err=True) + sys.exit(1) + + engine = p.get("engine", "renode") + arch = p.get("arch", "unknown") + click.echo(f"EoSim: simulating {platform} ({arch}) via {engine}") + + log_dir = "out/logs" + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, platform + ".log") + + if engine == "renode": + _run_renode(p, platform, headless, duration, log_file) + elif engine == "qemu": + _run_qemu(p, platform, headless, duration, log_file) + elif engine == "eosim": + _run_eosim(p, platform, headless, duration, log_file) + else: + click.echo("Unknown engine: " + engine, err=True) + sys.exit(1) + + if nested_install: + click.echo(f"Nested install test: simulated for {platform}") + + +@cli.command("list-platforms") +def list_platforms_alias(): + """List available simulation platforms (alias).""" + reg = _load_registry() + platforms = reg.all() + click.echo("Available platforms (%d):\n" % len(platforms)) + _print_platforms(platforms) + + +@cli.command() +@click.argument("platform") +@click.option("--output", default="out/artifacts", help="Output directory") +def artifact(platform, output): + """Export simulation artifacts.""" + os.makedirs(output, exist_ok=True) + manifest = { + "platform": platform, + "version": __version__, + "artifacts": ["logs", "traces", "reports"], + } + manifest_path = os.path.join(output, platform + "-manifest.json") + with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) + click.echo("Artifacts exported to: " + output) + + +@cli.command() +def doctor(): + """Check EoSim environment health.""" + from eosim.core.host import HostEnvironment + env = HostEnvironment.detect() + + click.echo("EoSim Doctor\n") + click.echo("Host Environment:") + info = env.platform_info() + for key, val in info.items(): + click.echo(" %-20s %s" % (key, val)) + click.echo() + + click.echo("Simulation Engines:") + checks = [ + ("EoSim native", "built-in", False), + ("EoSim (x86_64)", "available", False), + ("EoSim (aarch64)", "available", False), + ("EoSim (arm)", "available", False), + ("EoSim (riscv64)", "available", False), + ("Renode", env.resolve_renode(), False), + ] + for name, path, required in checks: + if path: + click.echo(" %-20s OK (%s)" % (name, path)) + else: + status = "MISSING" if required else "not found (optional)" + click.echo(" %-20s %s" % (name, status)) + + click.echo("\nPython Packages:") + for pkg in ["click", "pyyaml", "pytest"]: + try: + __import__(pkg.replace("-", "_")) + click.echo(" %-20s installed" % pkg) + except ImportError: + click.echo(" %-20s MISSING" % pkg) + + click.echo("\nPlatform Registry:") + reg = _load_registry() + click.echo(" %-20s %d" % ("Total platforms", reg.count())) + click.echo(" %-20s %s" % ("Vendors", ", ".join(reg.vendors()) or "none")) + click.echo(" %-20s %s" % ("Architectures", ", ".join(reg.arches()) or "none")) + + +# --- Domain subcommands --- + +@cli.group() +def domain(): + """Simulation domain categories and profiles.""" + pass + + +@domain.command("list") +def domain_list(): + """List all simulation domain categories.""" + from eosim.core.domains import DOMAIN_CATALOG + click.echo("Simulation Domains (%d):\n" % len(DOMAIN_CATALOG)) + for name, profile in sorted(DOMAIN_CATALOG.items()): + click.echo(" %-15s %s" % (name, profile.display_name)) + click.echo(" %-15s %s" % ("", profile.description)) + click.echo() + + +@domain.command("info") +@click.argument("name") +def domain_info(name): + """Show detailed domain profile.""" + from eosim.core.domains import get_domain + d = get_domain(name) + if not d: + click.echo(f"Unknown domain: {name}", err=True) + sys.exit(1) + click.echo(f"Domain: {d.display_name}") + click.echo(f"Description: {d.description}") + if d.safety_levels: + click.echo("Safety Levels: {}".format(", ".join(d.safety_levels))) + click.echo("Standards: {}".format(", ".join(d.standards))) + click.echo("Typical Arches: {}".format(", ".join(d.typical_arches))) + click.echo("Typical Classes: {}".format(", ".join(d.typical_classes))) + if d.test_scenarios: + click.echo("Test Scenarios: {}".format(", ".join(d.test_scenarios))) + + +# --- Modeling subcommands --- + +@cli.group() +def modeling(): + """Simulation modeling methods and parameters.""" + pass + + +@modeling.command("list") +def modeling_list(): + """List all modeling methods.""" + from eosim.core.modeling import MODELING_CATALOG + click.echo("Modeling Methods (%d):\n" % len(MODELING_CATALOG)) + for name, method in sorted(MODELING_CATALOG.items()): + engines = ", ".join(method.engine_support) + click.echo(" %-20s %s (engines: %s)" % (name, method.display_name, engines)) + + +@modeling.command("info") +@click.argument("name") +def modeling_info(name): + """Show detailed modeling method info.""" + from eosim.core.modeling import get_modeling + m = get_modeling(name) + if not m: + click.echo(f"Unknown modeling method: {name}", err=True) + sys.exit(1) + click.echo(f"Method: {m.display_name}") + click.echo(f"Description: {m.description}") + click.echo("Supported Engines: {}".format(", ".join(m.engine_support))) + click.echo("Use Cases: {}".format(", ".join(m.use_cases))) + if m.parameters: + click.echo("Parameters:") + for pname, ptype in m.parameters.items(): + click.echo(" %-20s %s" % (pname, ptype)) + + +# --- EoS integration subcommands --- + +@cli.group() +def eos(): + """EoS integration — build, test, and validate EoS through EoSim.""" + pass + + +@eos.command("find") +def eos_find(): + """Find EoS source code on this system.""" + from eosim.integrations.eos_runner import find_eos_source + src = find_eos_source() + if src: + click.echo("EoS source found: " + src) + else: + click.echo("EoS source not found. Set EOS_SOURCE env var or clone to ./eos") + + +@eos.command("build") +@click.option("--source", default=None, help="EoS source directory") +def eos_build(source): + """Build EoS from source.""" + from eosim.integrations.eos_runner import build_eos, find_eos_source + src = source or find_eos_source() + if not src: + click.echo("EoS source not found", err=True) + sys.exit(1) + click.echo("Building EoS from: " + src) + ok, log = build_eos(src) + if ok: + click.echo("BUILD: PASSED") + else: + click.echo("BUILD: FAILED") + click.echo(log[-500:] if len(log) > 500 else log) + sys.exit(1) + + +@eos.command("test") +@click.option("--source", default=None, help="EoS source directory") +@click.option("--verbose", "-v", is_flag=True, help="Show test output") +def eos_test(source, verbose): + """Build and run all EoS unit tests.""" + from eosim.integrations.eos_runner import find_eos_source, run_eos_tests + src = source or find_eos_source() + if not src: + click.echo("EoS source not found", err=True) + sys.exit(1) + click.echo("EoSim: Building and testing EoS from: " + src) + click.echo("") + suite = run_eos_tests(src) + click.echo(suite.summary()) + if verbose: + for r in suite.results: + if r.output and not r.passed: + click.echo(f"\n--- {r.name} ---") + click.echo(r.output[-300:]) + if suite.failed > 0: + sys.exit(1) + + +@eos.command("test-suite") +@click.option("--source", default=None, help="eApps source directory") +def eos_test_suite(source): + """Run eApps Python tests.""" + from eosim.integrations.eos_runner import run_eosuite_tests + candidates = [ + source, + os.path.join(os.getcwd(), "..", "eApps"), + os.path.expanduser("~/EoS/eApps"), + "C:/Users/spatchava/EoS/eApps", + "/mnt/c/Users/spatchava/EoS/eApps", + ] + src = None + for c in candidates: + if c and os.path.isdir(c) and os.path.exists(os.path.join(c, "tests")): + src = c + break + if not src: + click.echo("eApps source not found", err=True) + sys.exit(1) + click.echo("EoSim: Testing eApps from: " + src) + suite = run_eosuite_tests(src) + click.echo(suite.summary()) + + +@cli.command("ecosystem") +@click.option("--workspace", default=None, help="EoS workspace root") +@click.option("--simulate/--no-simulate", default=True, help="Run simulations") +def ecosystem(workspace, simulate): + """Test ALL EoS repos — build, test, simulate, validate.""" + from eosim.integrations.ecosystem import find_repos, run_ecosystem_tests + click.echo("EoSim Ecosystem Validation") + click.echo("") + repos = find_repos(workspace) + if not repos: + click.echo("No EoS repos found. Set --workspace or EOS_WORKSPACE env var.", err=True) + sys.exit(1) + click.echo("Found %d repos: %s" % (len(repos), ", ".join(repos.keys()))) + click.echo("") + report = run_ecosystem_tests(workspace) + click.echo(report.summary()) + if report.repos_failed > 0: + sys.exit(1) + + +@cli.command() +def gui(): + """Launch the EoSim simulation UI.""" + import tkinter as tk + + from eosim.gui.tk_app import TkSimulatorApp + + root = tk.Tk() + root.title("EoSim — Embedded Simulator") + root.geometry("1200x750") + root.minsize(900, 600) + + app = TkSimulatorApp(root) + app.pack(fill=tk.BOTH, expand=True) + + root.protocol("WM_DELETE_WINDOW", lambda: (app.on_close(), root.destroy())) + root.mainloop() + + +# --- HIL subcommands --- + +@cli.group() +def hil(): + """Hardware-in-the-loop — connect to real development boards.""" + pass + + +@hil.command("detect") +def hil_detect(): + """Detect connected debug probes and serial ports.""" + from eosim.integrations.openocd import OpenOCDManager + from eosim.integrations.serial_bridge import SerialBridge + + click.echo("EoSim HIL — Device Detection\n") + + openocd = OpenOCDManager.find_openocd() + if openocd: + click.echo(f"OpenOCD: {openocd}") + else: + click.echo("OpenOCD: NOT FOUND (install from https://openocd.org/)") + + click.echo("\nSerial Ports:") + if SerialBridge.available(): + ports = SerialBridge.list_ports() + if ports: + for p in ports: + click.echo(" %-15s %s" % (p['device'], p['description'])) + else: + click.echo(" (none found)") + + boards = SerialBridge.detect_dev_boards() + if boards: + click.echo("\nDetected Dev Boards:") + for b in boards: + click.echo(" %-15s %s" % (b['device'], b['board'])) + else: + click.echo(" pyserial not installed — run: pip install pyserial") + + +@hil.command("connect") +@click.option("--adapter", default="stlink", help="Debug adapter (stlink, jlink, cmsis-dap)") +@click.option("--target", default="stm32f4", help="Target MCU (stm32f4, nrf52, etc.)") +@click.option("--serial", "serial_port", default="", help="Serial port (COM3, /dev/ttyUSB0)") +@click.option("--baudrate", default=115200, help="Serial baud rate") +@click.option("--gdb-port", default=3333, help="GDB port") +def hil_connect(adapter, target, serial_port, baudrate, gdb_port): + """Connect to a real development board via OpenOCD.""" + from eosim.integrations.hil_session import HILSession + + click.echo(f"EoSim HIL — Connecting to {target} via {adapter}") + session = HILSession() + try: + session.start( + adapter=adapter, target=target, + serial_port=serial_port, baudrate=baudrate, + gdb_port=gdb_port, + ) + click.echo("Connected! GDB on port %d" % gdb_port) + if serial_port: + click.echo("Serial bridge: %s @ %d baud" % (serial_port, baudrate)) + + state = session.get_state() + click.echo("\nSession state:") + for k, v in state.items(): + click.echo(" %-20s %s" % (k, v)) + + click.echo("\nPress Ctrl+C to disconnect...") + try: + import time + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + except Exception as e: + click.echo(f"Connection failed: {e}", err=True) + sys.exit(1) + finally: + session.stop() + click.echo("Disconnected.") + + +@hil.command("flash") +@click.argument("firmware", type=click.Path(exists=True)) +@click.option("--adapter", default="stlink", help="Debug adapter") +@click.option("--target", default="stm32f4", help="Target MCU") +def hil_flash(firmware, adapter, target): + """Flash firmware to a real target via OpenOCD.""" + from eosim.integrations.openocd import OpenOCDManager + + click.echo(f"EoSim HIL — Flashing {firmware} to {target} via {adapter}") + mgr = OpenOCDManager() + try: + ok = mgr.flash(firmware) + if ok: + click.echo("Flash: PASSED") + else: + click.echo("Flash: FAILED") + sys.exit(1) + except Exception as e: + click.echo(f"Flash error: {e}", err=True) + sys.exit(1) + + +@hil.command("monitor") +@click.option("--adapter", default="stlink", help="Debug adapter") +@click.option("--target", default="stm32f4", help="Target MCU") +@click.option("--gdb-port", default=3333, help="GDB port") +def hil_monitor(adapter, target, gdb_port): + """Live register/memory monitoring (text mode).""" + from eosim.integrations.hil_session import HILSession + + click.echo(f"EoSim HIL Monitor — {target} via {adapter} (Ctrl+C to quit)\n") + session = HILSession() + try: + session.start(adapter=adapter, target=target, gdb_port=gdb_port) + session.halt() + import time + while True: + regs = session.read_registers() + if regs: + click.echo("\033[2J\033[H") + click.echo(f"=== {target} Registers ===") + for name, val in sorted(regs.items()): + click.echo(" %-6s 0x%08X" % (name, val)) + time.sleep(0.5) + except KeyboardInterrupt: + pass + finally: + session.stop() + + +# --- Bridge subcommands (external tool integrations) --- + +@cli.group() +def bridge(): + """External tool bridges — X-Plane, Gazebo, OpenFOAM.""" + pass + + +@bridge.command("status") +def bridge_status(): + """Show status of all external tool bridges.""" + from eosim.engine.backend import ( + AirSimEngine, CARLAEngine, GazeboEngine, OpenFOAMEngine, + ROS2Engine, XPlaneEngine, + ) + click.echo("EoSim Bridge Status\n") + click.echo(" %-15s %s" % ("X-Plane", "available" if XPlaneEngine.available() else "not connected")) + click.echo(" %-15s %s" % ("Gazebo", "available" if GazeboEngine.available() else "not installed")) + click.echo(" %-15s %s" % ("OpenFOAM", "available" if OpenFOAMEngine.available() else "not installed")) + click.echo(" %-15s %s" % ("CARLA", "available" if CARLAEngine.available() else "not connected")) + click.echo(" %-15s %s" % ("AirSim", "available" if AirSimEngine.available() else "not connected")) + click.echo(" %-15s %s" % ("ROS 2", "available" if ROS2Engine.available() else "not installed")) + + +@bridge.group("xplane") +def bridge_xplane(): + """X-Plane flight simulator bridge.""" + pass + + +@bridge_xplane.command("connect") +@click.option("--host", default="127.0.0.1", help="X-Plane host") +@click.option("--port", default=49000, help="X-Plane UDP port") +def xplane_connect(host, port): + """Connect to X-Plane simulator.""" + from eosim.integrations.xplane import XPlaneConnection + click.echo("Connecting to X-Plane at %s:%d..." % (host, port)) + conn = XPlaneConnection(host=host, port=port) + if conn.connect(): + click.echo("Connected to X-Plane") + status = conn.get_status() + for k, v in status.items(): + click.echo(" %-20s %s" % (k, v)) + conn.disconnect() + else: + click.echo("Failed to connect to X-Plane", err=True) + sys.exit(1) + + +@bridge.group("gazebo") +def bridge_gazebo(): + """Gazebo simulation bridge.""" + pass + + +@bridge_gazebo.command("connect") +@click.option("--host", default="127.0.0.1", help="Gazebo host") +@click.option("--port", default=11345, help="Gazebo port") +def gazebo_connect(host, port): + """Connect to Gazebo simulator.""" + from eosim.integrations.gazebo import GazeboConnection + click.echo("Connecting to Gazebo at %s:%d..." % (host, port)) + conn = GazeboConnection(host=host, port=port) + if conn.connect(): + click.echo("Connected to Gazebo") + status = conn.get_status() + for k, v in status.items(): + click.echo(" %-20s %s" % (k, v)) + conn.disconnect() + else: + click.echo("Failed to connect to Gazebo", err=True) + sys.exit(1) + + +@bridge.group("openfoam") +def bridge_openfoam(): + """OpenFOAM CFD solver bridge.""" + pass + + +@bridge_openfoam.command("run") +@click.option("--case-dir", required=True, help="OpenFOAM case directory") +@click.option("--solver", default="simpleFoam", help="Solver name") +def openfoam_run(case_dir, solver): + """Run an OpenFOAM simulation.""" + from eosim.integrations.openfoam import OpenFOAMRunner + click.echo(f"Running OpenFOAM solver '{solver}' on case: {case_dir}") + runner = OpenFOAMRunner(case_dir=case_dir) + runner.set_solver(solver) + result = runner.run() + if result['success']: + click.echo("Solver completed successfully") + if result.get('converged'): + click.echo("Solution converged") + else: + click.echo("Solver failed") + click.echo(result.get('log', '')[-500:]) + sys.exit(1) + + +if __name__ == "__main__": + cli() + + +# --- API Server command --- + +@cli.command("api") +@click.option("--host", default="0.0.0.0", help="API server host") +@click.option("--port", default=8080, help="API server port") +def api_server(host, port): + """Start the EoSim REST API server.""" + click.echo(f"EoSim API Server starting on {host}:{port}") + click.echo("Swagger UI: http://%s:%d/docs" % (host if host != "0.0.0.0" else "localhost", port)) + from eosim.api.server import EoSimAPIServer + server = EoSimAPIServer(host=host, port=port) + server.run() + + +# --- Simulators command --- + +@cli.group("simulator") +def simulator_group(): + """Simulator management — list types, products, scenarios.""" + pass + + +@simulator_group.command("list") +def simulator_list(): + """List all available simulator types.""" + from eosim.engine.native.simulators import SimulatorFactory + sims = SimulatorFactory.list_simulators() + click.echo("Available Simulators (%d):\n" % len(sims)) + for s in sims: + click.echo(" " + s) + + +@simulator_group.command("products") +def simulator_products(): + """List all product templates.""" + from eosim.gui.product_templates import PRODUCT_CATALOG + click.echo("Product Templates (%d):\n" % len(PRODUCT_CATALOG)) + click.echo(" %-25s %-25s %-15s %s" % ("NAME", "DISPLAY", "DOMAIN", "SIMULATOR")) + click.echo(" " + "-" * 90) + for name, t in sorted(PRODUCT_CATALOG.items()): + click.echo(" %-25s %-25s %-15s %s" % ( + name, t.display_name, t.domain, t.simulator_class)) + + +@simulator_group.command("run") +@click.argument("product_type") +@click.option("--ticks", default=100, help="Number of simulation ticks") +@click.option("--scenario", default="", help="Load a named scenario") +def simulator_run(product_type, ticks, scenario): + """Run a product simulator interactively.""" + from eosim.engine.native.simulators import SimulatorFactory, SIMULATOR_MAP + if product_type not in SIMULATOR_MAP: + click.echo(f"Unknown product type: {product_type}", err=True) + click.echo("Available: " + ", ".join(sorted(SIMULATOR_MAP.keys())[:20]) + " ...") + sys.exit(1) + + class VM: + peripherals = {} + def add_peripheral(self, name, dev): + self.peripherals[name] = dev + + vm = VM() + sim = SimulatorFactory.create(product_type, vm) + click.echo(f"Simulator: {sim.DISPLAY_NAME} ({sim.PRODUCT_TYPE})") + click.echo(f"Peripherals: {len(vm.peripherals)}") + + if scenario: + sim.load_scenario(scenario) + click.echo(f"Scenario: {scenario}") + + click.echo(f"Running {ticks} ticks...\n") + for i in range(ticks): + sim.tick() + if (i + 1) % (ticks // 5 or 1) == 0: + click.echo(f" Tick {i+1}: {sim.get_status_text()}") + + click.echo(f"\nFinal state:") + for k, v in sim.get_state().items(): + if k != 'scenario': + click.echo(f" {k}: {v}") diff --git a/eosim/engine/native/__init__.py b/eosim/engine/native/__init__.py index 8c99439..8b5c877 100644 --- a/eosim/engine/native/__init__.py +++ b/eosim/engine/native/__init__.py @@ -29,6 +29,10 @@ def __init__(self, name: str = 'eosim-vm', arch: str = 'arm64', self.peripherals: dict = {} self.start_time = 0.0 self.cycles_executed = 0 + # Whether real code was mapped. Without it the CPU steps over zeroed + # memory, which must not be reported as a boot. See run(). + self.firmware_loaded = False + self.firmware_path: Optional[str] = None # Add RAM self.bus.add_region(MemoryRegion('ram', 0x20000000, ram_mb * 1024 * 1024)) @@ -65,37 +69,73 @@ def load_firmware(self, path: str, addr: int = 0x08000000) -> bool: flash = MemoryRegion('firmware', addr, len(data), bytearray(data), readonly=True) self.bus.add_region(flash) self.cpu.reset(entry=addr, stack=0x20000000 + 512 * 1024) + self.firmware_loaded = True + self.firmware_path = path return True def load_binary(self, data: bytes, addr: int = 0x08000000): region = MemoryRegion('binary', addr, len(data), bytearray(data)) self.bus.add_region(region) self.cpu.reset(entry=addr, stack=0x20000000 + 512 * 1024) + self.firmware_loaded = True + self.firmware_path = '' def run(self, max_cycles: int = 100000, timeout_s: float = 30.0) -> dict: + """Execute until halt, cycle budget, or timeout. + + `success` reports what happened. It used to be the literal True and the + engine printed "EoS booted successfully" unconditionally, so a run over + zeroed memory with no firmware loaded reported a successful EoS boot. + Nothing distinguished that from a real one. + """ self.running = True self.start_time = time.time() self.boot_log.clear() - # Boot message - self._uart_print(f'EoSim Virtual Machine: {self.name} ({self.arch})\\n') - self._uart_print('RAM: %d MB | Peripherals: %d\\n' % ( - sum(r.size for r in self.bus.regions if r.name == 'ram') // (1024*1024), + self._uart_print('EoSim Virtual Machine: %s (%s)\n' % (self.name, self.arch)) + self._uart_print('RAM: %d MB | Peripherals: %d\n' % ( + sum(r.size for r in self.bus.regions if r.name == 'ram') // (1024 * 1024), len(self.peripherals))) - self._uart_print('Booting...\\n') + + if not self.firmware_loaded: + # Stepping over zeroed memory executes NOP 10000 times. That is not + # a boot, and calling it one is how a simulator stops being evidence. + self._uart_print( + 'No firmware loaded - nothing to execute.\n' + 'Load an image with load_firmware(path) or `eosim run ' + '--firmware `.\n') + self.running = False + elapsed = time.time() - self.start_time + return { + 'success': False, + 'reason': 'no-firmware', + 'cycles': 0, + 'duration_s': elapsed, + 'boot_log': self.get_uart_output(), + 'cpu_state': self.cpu.state.dump(), + } + + self._uart_print('Booting %s...\n' % (self.firmware_path or 'image')) executed = 0 + reason = 'cycle-limit' while self.running and executed < max_cycles: elapsed = time.time() - self.start_time if elapsed > timeout_s: - self._uart_print(f'\\nTimeout after {elapsed:.1f}s\\n') + reason = 'timeout' + self._uart_print('\nTimeout after %.1fs\n' % elapsed) break - if not self.cpu.step(): - break + # The instruction retires whether or not it halts the core, so it + # is counted before the break. Previously the halting instruction + # was executed but not counted, leaving run()['cycles'] one behind + # cpu.state.cycles for every program that halts. + stepped = self.cpu.step() executed += 1 + if not stepped: + reason = 'halted' + break - # Tick timer if executed % 100 == 0: timer = self.peripherals.get('timer0') if timer: @@ -105,11 +145,16 @@ def run(self, max_cycles: int = 100000, timeout_s: float = 30.0) -> dict: self.cycles_executed = executed elapsed = time.time() - self.start_time - self._uart_print('\\nSimulation complete: %d cycles in %.3fs\\n' % (executed, elapsed)) - self._uart_print('EoS booted successfully\\n') + # A clean halt (UDF/breakpoint) is the only outcome the firmware chose. + # Exhausting the cycle budget or the clock means we stopped it. + success = reason == 'halted' + + self._uart_print('\nSimulation stopped (%s): %d cycles in %.3fs\n' + % (reason, executed, elapsed)) return { - 'success': True, + 'success': success, + 'reason': reason, 'cycles': executed, 'duration_s': elapsed, 'boot_log': self.get_uart_output(), @@ -139,8 +184,8 @@ def get_status(self) -> dict: def dump_state(self) -> str: lines = [f'=== EoSim VM: {self.name} ==='] lines.append(self.cpu.state.dump()) - lines.append('\\nPeripherals: {}'.format(', '.join(self.peripherals.keys()))) + lines.append('\nPeripherals: {}'.format(', '.join(self.peripherals.keys()))) lines.append('Memory regions:') for r in self.bus.regions: lines.append(' %-10s 0x%08X %d bytes' % (r.name, r.base, r.size)) - return '\\n'.join(lines) + return '\n'.join(lines) diff --git a/platforms/adi-aducm4050/platform.yml b/eosim/platforms/adi-aducm4050/platform.yml similarity index 100% rename from platforms/adi-aducm4050/platform.yml rename to eosim/platforms/adi-aducm4050/platform.yml diff --git a/platforms/aerodynamics-sim/platform.yml b/eosim/platforms/aerodynamics-sim/platform.yml similarity index 100% rename from platforms/aerodynamics-sim/platform.yml rename to eosim/platforms/aerodynamics-sim/platform.yml diff --git a/platforms/aerodynamics-sim/tests.yml b/eosim/platforms/aerodynamics-sim/tests.yml similarity index 100% rename from platforms/aerodynamics-sim/tests.yml rename to eosim/platforms/aerodynamics-sim/tests.yml diff --git a/platforms/agriculture-sim/platform.yml b/eosim/platforms/agriculture-sim/platform.yml similarity index 100% rename from platforms/agriculture-sim/platform.yml rename to eosim/platforms/agriculture-sim/platform.yml diff --git a/platforms/allwinner-d1/platform.yml b/eosim/platforms/allwinner-d1/platform.yml similarity index 100% rename from platforms/allwinner-d1/platform.yml rename to eosim/platforms/allwinner-d1/platform.yml diff --git a/platforms/am62x/platform.yml b/eosim/platforms/am62x/platform.yml similarity index 100% rename from platforms/am62x/platform.yml rename to eosim/platforms/am62x/platform.yml diff --git a/platforms/am64x/platform.yml b/eosim/platforms/am64x/platform.yml similarity index 100% rename from platforms/am64x/platform.yml rename to eosim/platforms/am64x/platform.yml diff --git a/platforms/am64x/tests.yml b/eosim/platforms/am64x/tests.yml similarity index 100% rename from platforms/am64x/tests.yml rename to eosim/platforms/am64x/tests.yml diff --git a/platforms/ambiq-apollo4/platform.yml b/eosim/platforms/ambiq-apollo4/platform.yml similarity index 100% rename from platforms/ambiq-apollo4/platform.yml rename to eosim/platforms/ambiq-apollo4/platform.yml diff --git a/platforms/android-arm64/platform.yml b/eosim/platforms/android-arm64/platform.yml similarity index 100% rename from platforms/android-arm64/platform.yml rename to eosim/platforms/android-arm64/platform.yml diff --git a/platforms/android-tv/platform.yml b/eosim/platforms/android-tv/platform.yml similarity index 100% rename from platforms/android-tv/platform.yml rename to eosim/platforms/android-tv/platform.yml diff --git a/platforms/android-x86/platform.yml b/eosim/platforms/android-x86/platform.yml similarity index 100% rename from platforms/android-x86/platform.yml rename to eosim/platforms/android-x86/platform.yml diff --git a/platforms/apple-m1/platform.yml b/eosim/platforms/apple-m1/platform.yml similarity index 100% rename from platforms/apple-m1/platform.yml rename to eosim/platforms/apple-m1/platform.yml diff --git a/platforms/apple-tv/platform.yml b/eosim/platforms/apple-tv/platform.yml similarity index 100% rename from platforms/apple-tv/platform.yml rename to eosim/platforms/apple-tv/platform.yml diff --git a/platforms/arc-em/platform.yml b/eosim/platforms/arc-em/platform.yml similarity index 100% rename from platforms/arc-em/platform.yml rename to eosim/platforms/arc-em/platform.yml diff --git a/platforms/arc-em/tests.yml b/eosim/platforms/arc-em/tests.yml similarity index 100% rename from platforms/arc-em/tests.yml rename to eosim/platforms/arc-em/tests.yml diff --git a/platforms/arm-ethos-u55/platform.yml b/eosim/platforms/arm-ethos-u55/platform.yml similarity index 100% rename from platforms/arm-ethos-u55/platform.yml rename to eosim/platforms/arm-ethos-u55/platform.yml diff --git a/platforms/arm-mcu/nrf52.yml b/eosim/platforms/arm-mcu/nrf52.yml similarity index 100% rename from platforms/arm-mcu/nrf52.yml rename to eosim/platforms/arm-mcu/nrf52.yml diff --git a/platforms/arm-mcu/platform.yml b/eosim/platforms/arm-mcu/platform.yml similarity index 100% rename from platforms/arm-mcu/platform.yml rename to eosim/platforms/arm-mcu/platform.yml diff --git a/platforms/arm-mcu/rp2040.yml b/eosim/platforms/arm-mcu/rp2040.yml similarity index 100% rename from platforms/arm-mcu/rp2040.yml rename to eosim/platforms/arm-mcu/rp2040.yml diff --git a/platforms/arm-mcu/tests.yml b/eosim/platforms/arm-mcu/tests.yml similarity index 100% rename from platforms/arm-mcu/tests.yml rename to eosim/platforms/arm-mcu/tests.yml diff --git a/platforms/arm-vexpress/platform.yml b/eosim/platforms/arm-vexpress/platform.yml similarity index 100% rename from platforms/arm-vexpress/platform.yml rename to eosim/platforms/arm-vexpress/platform.yml diff --git a/platforms/arm64/eos.yml b/eosim/platforms/arm64/eos.yml similarity index 100% rename from platforms/arm64/eos.yml rename to eosim/platforms/arm64/eos.yml diff --git a/platforms/arm64/platform.yml b/eosim/platforms/arm64/platform.yml similarity index 100% rename from platforms/arm64/platform.yml rename to eosim/platforms/arm64/platform.yml diff --git a/platforms/arm64/tests.yml b/eosim/platforms/arm64/tests.yml similarity index 100% rename from platforms/arm64/tests.yml rename to eosim/platforms/arm64/tests.yml diff --git a/platforms/atmega2560/platform.yml b/eosim/platforms/atmega2560/platform.yml similarity index 100% rename from platforms/atmega2560/platform.yml rename to eosim/platforms/atmega2560/platform.yml diff --git a/platforms/atmega328p/platform.yml b/eosim/platforms/atmega328p/platform.yml similarity index 100% rename from platforms/atmega328p/platform.yml rename to eosim/platforms/atmega328p/platform.yml diff --git a/platforms/attiny85/platform.yml b/eosim/platforms/attiny85/platform.yml similarity index 100% rename from platforms/attiny85/platform.yml rename to eosim/platforms/attiny85/platform.yml diff --git a/platforms/aurix-tc3xx/platform.yml b/eosim/platforms/aurix-tc3xx/platform.yml similarity index 100% rename from platforms/aurix-tc3xx/platform.yml rename to eosim/platforms/aurix-tc3xx/platform.yml diff --git a/platforms/aurix-tc3xx/tests.yml b/eosim/platforms/aurix-tc3xx/tests.yml similarity index 100% rename from platforms/aurix-tc3xx/tests.yml rename to eosim/platforms/aurix-tc3xx/tests.yml diff --git a/platforms/beaglebone/platform.yml b/eosim/platforms/beaglebone/platform.yml similarity index 100% rename from platforms/beaglebone/platform.yml rename to eosim/platforms/beaglebone/platform.yml diff --git a/platforms/beaglebone/tests.yml b/eosim/platforms/beaglebone/tests.yml similarity index 100% rename from platforms/beaglebone/tests.yml rename to eosim/platforms/beaglebone/tests.yml diff --git a/platforms/bl602/platform.yml b/eosim/platforms/bl602/platform.yml similarity index 100% rename from platforms/bl602/platform.yml rename to eosim/platforms/bl602/platform.yml diff --git a/platforms/bl706/platform.yml b/eosim/platforms/bl706/platform.yml similarity index 100% rename from platforms/bl706/platform.yml rename to eosim/platforms/bl706/platform.yml diff --git a/platforms/canaan-k510/platform.yml b/eosim/platforms/canaan-k510/platform.yml similarity index 100% rename from platforms/canaan-k510/platform.yml rename to eosim/platforms/canaan-k510/platform.yml diff --git a/platforms/cc2652/platform.yml b/eosim/platforms/cc2652/platform.yml similarity index 100% rename from platforms/cc2652/platform.yml rename to eosim/platforms/cc2652/platform.yml diff --git a/platforms/cc3220/platform.yml b/eosim/platforms/cc3220/platform.yml similarity index 100% rename from platforms/cc3220/platform.yml rename to eosim/platforms/cc3220/platform.yml diff --git a/platforms/ch32v307/platform.yml b/eosim/platforms/ch32v307/platform.yml similarity index 100% rename from platforms/ch32v307/platform.yml rename to eosim/platforms/ch32v307/platform.yml diff --git a/platforms/cisco-catalyst-embedded/platform.yml b/eosim/platforms/cisco-catalyst-embedded/platform.yml similarity index 100% rename from platforms/cisco-catalyst-embedded/platform.yml rename to eosim/platforms/cisco-catalyst-embedded/platform.yml diff --git a/platforms/construction-sim/platform.yml b/eosim/platforms/construction-sim/platform.yml similarity index 100% rename from platforms/construction-sim/platform.yml rename to eosim/platforms/construction-sim/platform.yml diff --git a/platforms/cortex-r5/platform.yml b/eosim/platforms/cortex-r5/platform.yml similarity index 100% rename from platforms/cortex-r5/platform.yml rename to eosim/platforms/cortex-r5/platform.yml diff --git a/platforms/cortex-r5/tests.yml b/eosim/platforms/cortex-r5/tests.yml similarity index 100% rename from platforms/cortex-r5/tests.yml rename to eosim/platforms/cortex-r5/tests.yml diff --git a/platforms/cortex-r52/platform.yml b/eosim/platforms/cortex-r52/platform.yml similarity index 100% rename from platforms/cortex-r52/platform.yml rename to eosim/platforms/cortex-r52/platform.yml diff --git a/platforms/cortex-r52/tests.yml b/eosim/platforms/cortex-r52/tests.yml similarity index 100% rename from platforms/cortex-r52/tests.yml rename to eosim/platforms/cortex-r52/tests.yml diff --git a/platforms/dialog-da14695/platform.yml b/eosim/platforms/dialog-da14695/platform.yml similarity index 100% rename from platforms/dialog-da14695/platform.yml rename to eosim/platforms/dialog-da14695/platform.yml diff --git a/platforms/efm32gg/platform.yml b/eosim/platforms/efm32gg/platform.yml similarity index 100% rename from platforms/efm32gg/platform.yml rename to eosim/platforms/efm32gg/platform.yml diff --git a/platforms/efr32bg22/platform.yml b/eosim/platforms/efr32bg22/platform.yml similarity index 100% rename from platforms/efr32bg22/platform.yml rename to eosim/platforms/efr32bg22/platform.yml diff --git a/platforms/efr32mg24/platform.yml b/eosim/platforms/efr32mg24/platform.yml similarity index 100% rename from platforms/efr32mg24/platform.yml rename to eosim/platforms/efr32mg24/platform.yml diff --git a/platforms/ericsson-baseband/platform.yml b/eosim/platforms/ericsson-baseband/platform.yml similarity index 100% rename from platforms/ericsson-baseband/platform.yml rename to eosim/platforms/ericsson-baseband/platform.yml diff --git a/platforms/esp32/platform.yml b/eosim/platforms/esp32/platform.yml similarity index 100% rename from platforms/esp32/platform.yml rename to eosim/platforms/esp32/platform.yml diff --git a/platforms/esp32/tests.yml b/eosim/platforms/esp32/tests.yml similarity index 100% rename from platforms/esp32/tests.yml rename to eosim/platforms/esp32/tests.yml diff --git a/platforms/esp32c3/platform.yml b/eosim/platforms/esp32c3/platform.yml similarity index 100% rename from platforms/esp32c3/platform.yml rename to eosim/platforms/esp32c3/platform.yml diff --git a/platforms/esp32c3/tests.yml b/eosim/platforms/esp32c3/tests.yml similarity index 100% rename from platforms/esp32c3/tests.yml rename to eosim/platforms/esp32c3/tests.yml diff --git a/platforms/esp32s3/platform.yml b/eosim/platforms/esp32s3/platform.yml similarity index 100% rename from platforms/esp32s3/platform.yml rename to eosim/platforms/esp32s3/platform.yml diff --git a/platforms/esp32s3/tests.yml b/eosim/platforms/esp32s3/tests.yml similarity index 100% rename from platforms/esp32s3/tests.yml rename to eosim/platforms/esp32s3/tests.yml diff --git a/platforms/finance-sim/platform.yml b/eosim/platforms/finance-sim/platform.yml similarity index 100% rename from platforms/finance-sim/platform.yml rename to eosim/platforms/finance-sim/platform.yml diff --git a/platforms/finance-sim/tests.yml b/eosim/platforms/finance-sim/tests.yml similarity index 100% rename from platforms/finance-sim/tests.yml rename to eosim/platforms/finance-sim/tests.yml diff --git a/platforms/fire-tv/platform.yml b/eosim/platforms/fire-tv/platform.yml similarity index 100% rename from platforms/fire-tv/platform.yml rename to eosim/platforms/fire-tv/platform.yml diff --git a/platforms/gaming-sim/platform.yml b/eosim/platforms/gaming-sim/platform.yml similarity index 100% rename from platforms/gaming-sim/platform.yml rename to eosim/platforms/gaming-sim/platform.yml diff --git a/platforms/gaming-sim/tests.yml b/eosim/platforms/gaming-sim/tests.yml similarity index 100% rename from platforms/gaming-sim/tests.yml rename to eosim/platforms/gaming-sim/tests.yml diff --git a/platforms/gd32f303/platform.yml b/eosim/platforms/gd32f303/platform.yml similarity index 100% rename from platforms/gd32f303/platform.yml rename to eosim/platforms/gd32f303/platform.yml diff --git a/platforms/gd32vf103/platform.yml b/eosim/platforms/gd32vf103/platform.yml similarity index 100% rename from platforms/gd32vf103/platform.yml rename to eosim/platforms/gd32vf103/platform.yml diff --git a/platforms/gd32vf103/tests.yml b/eosim/platforms/gd32vf103/tests.yml similarity index 100% rename from platforms/gd32vf103/tests.yml rename to eosim/platforms/gd32vf103/tests.yml diff --git a/platforms/google-coral/platform.yml b/eosim/platforms/google-coral/platform.yml similarity index 100% rename from platforms/google-coral/platform.yml rename to eosim/platforms/google-coral/platform.yml diff --git a/platforms/gowin-gw1n/platform.yml b/eosim/platforms/gowin-gw1n/platform.yml similarity index 100% rename from platforms/gowin-gw1n/platform.yml rename to eosim/platforms/gowin-gw1n/platform.yml diff --git a/platforms/hailo-8/platform.yml b/eosim/platforms/hailo-8/platform.yml similarity index 100% rename from platforms/hailo-8/platform.yml rename to eosim/platforms/hailo-8/platform.yml diff --git a/platforms/holtek-ht32/platform.yml b/eosim/platforms/holtek-ht32/platform.yml similarity index 100% rename from platforms/holtek-ht32/platform.yml rename to eosim/platforms/holtek-ht32/platform.yml diff --git a/platforms/huawei-kunpeng/platform.yml b/eosim/platforms/huawei-kunpeng/platform.yml similarity index 100% rename from platforms/huawei-kunpeng/platform.yml rename to eosim/platforms/huawei-kunpeng/platform.yml diff --git a/platforms/imx8m/platform.yml b/eosim/platforms/imx8m/platform.yml similarity index 100% rename from platforms/imx8m/platform.yml rename to eosim/platforms/imx8m/platform.yml diff --git a/platforms/imx8m/tests.yml b/eosim/platforms/imx8m/tests.yml similarity index 100% rename from platforms/imx8m/tests.yml rename to eosim/platforms/imx8m/tests.yml diff --git a/platforms/imxrt1060/platform.yml b/eosim/platforms/imxrt1060/platform.yml similarity index 100% rename from platforms/imxrt1060/platform.yml rename to eosim/platforms/imxrt1060/platform.yml diff --git a/platforms/infineon-tc4xx/platform.yml b/eosim/platforms/infineon-tc4xx/platform.yml similarity index 100% rename from platforms/infineon-tc4xx/platform.yml rename to eosim/platforms/infineon-tc4xx/platform.yml diff --git a/platforms/intel-cyclone-v/platform.yml b/eosim/platforms/intel-cyclone-v/platform.yml similarity index 100% rename from platforms/intel-cyclone-v/platform.yml rename to eosim/platforms/intel-cyclone-v/platform.yml diff --git a/platforms/intel-movidius/platform.yml b/eosim/platforms/intel-movidius/platform.yml similarity index 100% rename from platforms/intel-movidius/platform.yml rename to eosim/platforms/intel-movidius/platform.yml diff --git a/platforms/ios-arm64/platform.yml b/eosim/platforms/ios-arm64/platform.yml similarity index 100% rename from platforms/ios-arm64/platform.yml rename to eosim/platforms/ios-arm64/platform.yml diff --git a/platforms/jetson-nano/platform.yml b/eosim/platforms/jetson-nano/platform.yml similarity index 100% rename from platforms/jetson-nano/platform.yml rename to eosim/platforms/jetson-nano/platform.yml diff --git a/platforms/jetson-nano/tests.yml b/eosim/platforms/jetson-nano/tests.yml similarity index 100% rename from platforms/jetson-nano/tests.yml rename to eosim/platforms/jetson-nano/tests.yml diff --git a/platforms/jetson-orin/platform.yml b/eosim/platforms/jetson-orin/platform.yml similarity index 100% rename from platforms/jetson-orin/platform.yml rename to eosim/platforms/jetson-orin/platform.yml diff --git a/platforms/jetson-orin/tests.yml b/eosim/platforms/jetson-orin/tests.yml similarity index 100% rename from platforms/jetson-orin/tests.yml rename to eosim/platforms/jetson-orin/tests.yml diff --git a/platforms/k64f/platform.yml b/eosim/platforms/k64f/platform.yml similarity index 100% rename from platforms/k64f/platform.yml rename to eosim/platforms/k64f/platform.yml diff --git a/platforms/k64f/tests.yml b/eosim/platforms/k64f/tests.yml similarity index 100% rename from platforms/k64f/tests.yml rename to eosim/platforms/k64f/tests.yml diff --git a/platforms/kendryte-k210/platform.yml b/eosim/platforms/kendryte-k210/platform.yml similarity index 100% rename from platforms/kendryte-k210/platform.yml rename to eosim/platforms/kendryte-k210/platform.yml diff --git a/platforms/kendryte-k210/tests.yml b/eosim/platforms/kendryte-k210/tests.yml similarity index 100% rename from platforms/kendryte-k210/tests.yml rename to eosim/platforms/kendryte-k210/tests.yml diff --git a/platforms/kneron-kl520/platform.yml b/eosim/platforms/kneron-kl520/platform.yml similarity index 100% rename from platforms/kneron-kl520/platform.yml rename to eosim/platforms/kneron-kl520/platform.yml diff --git a/platforms/lattice-ecp5/platform.yml b/eosim/platforms/lattice-ecp5/platform.yml similarity index 100% rename from platforms/lattice-ecp5/platform.yml rename to eosim/platforms/lattice-ecp5/platform.yml diff --git a/platforms/lattice-ice40/platform.yml b/eosim/platforms/lattice-ice40/platform.yml similarity index 100% rename from platforms/lattice-ice40/platform.yml rename to eosim/platforms/lattice-ice40/platform.yml diff --git a/platforms/logistics-sim/platform.yml b/eosim/platforms/logistics-sim/platform.yml similarity index 100% rename from platforms/logistics-sim/platform.yml rename to eosim/platforms/logistics-sim/platform.yml diff --git a/platforms/lpc4088/platform.yml b/eosim/platforms/lpc4088/platform.yml similarity index 100% rename from platforms/lpc4088/platform.yml rename to eosim/platforms/lpc4088/platform.yml diff --git a/platforms/lpc55s69/platform.yml b/eosim/platforms/lpc55s69/platform.yml similarity index 100% rename from platforms/lpc55s69/platform.yml rename to eosim/platforms/lpc55s69/platform.yml diff --git a/platforms/maritime-sim/platform.yml b/eosim/platforms/maritime-sim/platform.yml similarity index 100% rename from platforms/maritime-sim/platform.yml rename to eosim/platforms/maritime-sim/platform.yml diff --git a/platforms/max32660/platform.yml b/eosim/platforms/max32660/platform.yml similarity index 100% rename from platforms/max32660/platform.yml rename to eosim/platforms/max32660/platform.yml diff --git a/platforms/mcxn947/platform.yml b/eosim/platforms/mcxn947/platform.yml similarity index 100% rename from platforms/mcxn947/platform.yml rename to eosim/platforms/mcxn947/platform.yml diff --git a/platforms/mediatek-mt7688/platform.yml b/eosim/platforms/mediatek-mt7688/platform.yml similarity index 100% rename from platforms/mediatek-mt7688/platform.yml rename to eosim/platforms/mediatek-mt7688/platform.yml diff --git a/platforms/microblaze/platform.yml b/eosim/platforms/microblaze/platform.yml similarity index 100% rename from platforms/microblaze/platform.yml rename to eosim/platforms/microblaze/platform.yml diff --git a/platforms/microblaze/tests.yml b/eosim/platforms/microblaze/tests.yml similarity index 100% rename from platforms/microblaze/tests.yml rename to eosim/platforms/microblaze/tests.yml diff --git a/platforms/mikrotik-routerboard/platform.yml b/eosim/platforms/mikrotik-routerboard/platform.yml similarity index 100% rename from platforms/mikrotik-routerboard/platform.yml rename to eosim/platforms/mikrotik-routerboard/platform.yml diff --git a/platforms/milkv-duo/platform.yml b/eosim/platforms/milkv-duo/platform.yml similarity index 100% rename from platforms/milkv-duo/platform.yml rename to eosim/platforms/milkv-duo/platform.yml diff --git a/platforms/mining-sim/platform.yml b/eosim/platforms/mining-sim/platform.yml similarity index 100% rename from platforms/mining-sim/platform.yml rename to eosim/platforms/mining-sim/platform.yml diff --git a/platforms/mipsel/platform.yml b/eosim/platforms/mipsel/platform.yml similarity index 100% rename from platforms/mipsel/platform.yml rename to eosim/platforms/mipsel/platform.yml diff --git a/platforms/msp430/platform.yml b/eosim/platforms/msp430/platform.yml similarity index 100% rename from platforms/msp430/platform.yml rename to eosim/platforms/msp430/platform.yml diff --git a/platforms/mythic-amp/platform.yml b/eosim/platforms/mythic-amp/platform.yml similarity index 100% rename from platforms/mythic-amp/platform.yml rename to eosim/platforms/mythic-amp/platform.yml diff --git a/platforms/nokia-fpga-radio/platform.yml b/eosim/platforms/nokia-fpga-radio/platform.yml similarity index 100% rename from platforms/nokia-fpga-radio/platform.yml rename to eosim/platforms/nokia-fpga-radio/platform.yml diff --git a/platforms/nrf52/platform.yml b/eosim/platforms/nrf52/platform.yml similarity index 100% rename from platforms/nrf52/platform.yml rename to eosim/platforms/nrf52/platform.yml diff --git a/platforms/nrf52/tests.yml b/eosim/platforms/nrf52/tests.yml similarity index 100% rename from platforms/nrf52/tests.yml rename to eosim/platforms/nrf52/tests.yml diff --git a/platforms/nrf5340/platform.yml b/eosim/platforms/nrf5340/platform.yml similarity index 100% rename from platforms/nrf5340/platform.yml rename to eosim/platforms/nrf5340/platform.yml diff --git a/platforms/nrf9160/platform.yml b/eosim/platforms/nrf9160/platform.yml similarity index 100% rename from platforms/nrf9160/platform.yml rename to eosim/platforms/nrf9160/platform.yml diff --git a/platforms/nuclear-sim/platform.yml b/eosim/platforms/nuclear-sim/platform.yml similarity index 100% rename from platforms/nuclear-sim/platform.yml rename to eosim/platforms/nuclear-sim/platform.yml diff --git a/platforms/nuvoton-m480/platform.yml b/eosim/platforms/nuvoton-m480/platform.yml similarity index 100% rename from platforms/nuvoton-m480/platform.yml rename to eosim/platforms/nuvoton-m480/platform.yml diff --git a/platforms/nxp-s32g/platform.yml b/eosim/platforms/nxp-s32g/platform.yml similarity index 100% rename from platforms/nxp-s32g/platform.yml rename to eosim/platforms/nxp-s32g/platform.yml diff --git a/platforms/omap-l138/platform.yml b/eosim/platforms/omap-l138/platform.yml similarity index 100% rename from platforms/omap-l138/platform.yml rename to eosim/platforms/omap-l138/platform.yml diff --git a/platforms/onsemi-rsl10/platform.yml b/eosim/platforms/onsemi-rsl10/platform.yml similarity index 100% rename from platforms/onsemi-rsl10/platform.yml rename to eosim/platforms/onsemi-rsl10/platform.yml diff --git a/platforms/physiology-sim/platform.yml b/eosim/platforms/physiology-sim/platform.yml similarity index 100% rename from platforms/physiology-sim/platform.yml rename to eosim/platforms/physiology-sim/platform.yml diff --git a/platforms/physiology-sim/tests.yml b/eosim/platforms/physiology-sim/tests.yml similarity index 100% rename from platforms/physiology-sim/tests.yml rename to eosim/platforms/physiology-sim/tests.yml diff --git a/platforms/pic18f/platform.yml b/eosim/platforms/pic18f/platform.yml similarity index 100% rename from platforms/pic18f/platform.yml rename to eosim/platforms/pic18f/platform.yml diff --git a/platforms/pic32mx/platform.yml b/eosim/platforms/pic32mx/platform.yml similarity index 100% rename from platforms/pic32mx/platform.yml rename to eosim/platforms/pic32mx/platform.yml diff --git a/platforms/pic32mz/platform.yml b/eosim/platforms/pic32mz/platform.yml similarity index 100% rename from platforms/pic32mz/platform.yml rename to eosim/platforms/pic32mz/platform.yml diff --git a/platforms/ppce500/platform.yml b/eosim/platforms/ppce500/platform.yml similarity index 100% rename from platforms/ppce500/platform.yml rename to eosim/platforms/ppce500/platform.yml diff --git a/platforms/ppce500/tests.yml b/eosim/platforms/ppce500/tests.yml similarity index 100% rename from platforms/ppce500/tests.yml rename to eosim/platforms/ppce500/tests.yml diff --git a/platforms/ps5-embedded/platform.yml b/eosim/platforms/ps5-embedded/platform.yml similarity index 100% rename from platforms/ps5-embedded/platform.yml rename to eosim/platforms/ps5-embedded/platform.yml diff --git a/platforms/psoc6/platform.yml b/eosim/platforms/psoc6/platform.yml similarity index 100% rename from platforms/psoc6/platform.yml rename to eosim/platforms/psoc6/platform.yml diff --git a/platforms/qemu-q35/platform.yml b/eosim/platforms/qemu-q35/platform.yml similarity index 100% rename from platforms/qemu-q35/platform.yml rename to eosim/platforms/qemu-q35/platform.yml diff --git a/platforms/qemu-q35/tests.yml b/eosim/platforms/qemu-q35/tests.yml similarity index 100% rename from platforms/qemu-q35/tests.yml rename to eosim/platforms/qemu-q35/tests.yml diff --git a/platforms/qualcomm-qcs610/platform.yml b/eosim/platforms/qualcomm-qcs610/platform.yml similarity index 100% rename from platforms/qualcomm-qcs610/platform.yml rename to eosim/platforms/qualcomm-qcs610/platform.yml diff --git a/platforms/ra4m1/platform.yml b/eosim/platforms/ra4m1/platform.yml similarity index 100% rename from platforms/ra4m1/platform.yml rename to eosim/platforms/ra4m1/platform.yml diff --git a/platforms/railway-sim/platform.yml b/eosim/platforms/railway-sim/platform.yml similarity index 100% rename from platforms/railway-sim/platform.yml rename to eosim/platforms/railway-sim/platform.yml diff --git a/platforms/raspi-zero2w/platform.yml b/eosim/platforms/raspi-zero2w/platform.yml similarity index 100% rename from platforms/raspi-zero2w/platform.yml rename to eosim/platforms/raspi-zero2w/platform.yml diff --git a/platforms/raspi-zero2w/tests.yml b/eosim/platforms/raspi-zero2w/tests.yml similarity index 100% rename from platforms/raspi-zero2w/tests.yml rename to eosim/platforms/raspi-zero2w/tests.yml diff --git a/platforms/raspi2b/platform.yml b/eosim/platforms/raspi2b/platform.yml similarity index 100% rename from platforms/raspi2b/platform.yml rename to eosim/platforms/raspi2b/platform.yml diff --git a/platforms/raspi2b/tests.yml b/eosim/platforms/raspi2b/tests.yml similarity index 100% rename from platforms/raspi2b/tests.yml rename to eosim/platforms/raspi2b/tests.yml diff --git a/platforms/raspi3/platform.yml b/eosim/platforms/raspi3/platform.yml similarity index 100% rename from platforms/raspi3/platform.yml rename to eosim/platforms/raspi3/platform.yml diff --git a/platforms/raspi3/tests.yml b/eosim/platforms/raspi3/tests.yml similarity index 100% rename from platforms/raspi3/tests.yml rename to eosim/platforms/raspi3/tests.yml diff --git a/platforms/raspi4/platform.yml b/eosim/platforms/raspi4/platform.yml similarity index 100% rename from platforms/raspi4/platform.yml rename to eosim/platforms/raspi4/platform.yml diff --git a/platforms/raspi4/tests.yml b/eosim/platforms/raspi4/tests.yml similarity index 100% rename from platforms/raspi4/tests.yml rename to eosim/platforms/raspi4/tests.yml diff --git a/platforms/raspi5/platform.yml b/eosim/platforms/raspi5/platform.yml similarity index 100% rename from platforms/raspi5/platform.yml rename to eosim/platforms/raspi5/platform.yml diff --git a/platforms/raspi5/tests.yml b/eosim/platforms/raspi5/tests.yml similarity index 100% rename from platforms/raspi5/tests.yml rename to eosim/platforms/raspi5/tests.yml diff --git a/platforms/rcar-h3/platform.yml b/eosim/platforms/rcar-h3/platform.yml similarity index 100% rename from platforms/rcar-h3/platform.yml rename to eosim/platforms/rcar-h3/platform.yml diff --git a/platforms/rcar-s4/platform.yml b/eosim/platforms/rcar-s4/platform.yml similarity index 100% rename from platforms/rcar-s4/platform.yml rename to eosim/platforms/rcar-s4/platform.yml diff --git a/platforms/renesas-ra6m5/platform.yml b/eosim/platforms/renesas-ra6m5/platform.yml similarity index 100% rename from platforms/renesas-ra6m5/platform.yml rename to eosim/platforms/renesas-ra6m5/platform.yml diff --git a/platforms/renesas-rcar-s4/platform.yml b/eosim/platforms/renesas-rcar-s4/platform.yml similarity index 100% rename from platforms/renesas-rcar-s4/platform.yml rename to eosim/platforms/renesas-rcar-s4/platform.yml diff --git a/platforms/renesas-rh850/platform.yml b/eosim/platforms/renesas-rh850/platform.yml similarity index 100% rename from platforms/renesas-rh850/platform.yml rename to eosim/platforms/renesas-rh850/platform.yml diff --git a/platforms/riscv64/eos.yml b/eosim/platforms/riscv64/eos.yml similarity index 100% rename from platforms/riscv64/eos.yml rename to eosim/platforms/riscv64/eos.yml diff --git a/platforms/riscv64/platform.yml b/eosim/platforms/riscv64/platform.yml similarity index 100% rename from platforms/riscv64/platform.yml rename to eosim/platforms/riscv64/platform.yml diff --git a/platforms/riscv64/tests.yml b/eosim/platforms/riscv64/tests.yml similarity index 100% rename from platforms/riscv64/tests.yml rename to eosim/platforms/riscv64/tests.yml diff --git a/platforms/rl78/platform.yml b/eosim/platforms/rl78/platform.yml similarity index 100% rename from platforms/rl78/platform.yml rename to eosim/platforms/rl78/platform.yml diff --git a/platforms/roku-tv/platform.yml b/eosim/platforms/roku-tv/platform.yml similarity index 100% rename from platforms/roku-tv/platform.yml rename to eosim/platforms/roku-tv/platform.yml diff --git a/platforms/rp2040/platform.yml b/eosim/platforms/rp2040/platform.yml similarity index 100% rename from platforms/rp2040/platform.yml rename to eosim/platforms/rp2040/platform.yml diff --git a/platforms/rp2040/tests.yml b/eosim/platforms/rp2040/tests.yml similarity index 100% rename from platforms/rp2040/tests.yml rename to eosim/platforms/rp2040/tests.yml diff --git a/platforms/rx65n/platform.yml b/eosim/platforms/rx65n/platform.yml similarity index 100% rename from platforms/rx65n/platform.yml rename to eosim/platforms/rx65n/platform.yml diff --git a/platforms/rza2m/platform.yml b/eosim/platforms/rza2m/platform.yml similarity index 100% rename from platforms/rza2m/platform.yml rename to eosim/platforms/rza2m/platform.yml diff --git a/platforms/s32g274a/platform.yml b/eosim/platforms/s32g274a/platform.yml similarity index 100% rename from platforms/s32g274a/platform.yml rename to eosim/platforms/s32g274a/platform.yml diff --git a/platforms/s32k344/platform.yml b/eosim/platforms/s32k344/platform.yml similarity index 100% rename from platforms/s32k344/platform.yml rename to eosim/platforms/s32k344/platform.yml diff --git a/platforms/s32z/platform.yml b/eosim/platforms/s32z/platform.yml similarity index 100% rename from platforms/s32z/platform.yml rename to eosim/platforms/s32z/platform.yml diff --git a/platforms/samc21/platform.yml b/eosim/platforms/samc21/platform.yml similarity index 100% rename from platforms/samc21/platform.yml rename to eosim/platforms/samc21/platform.yml diff --git a/platforms/samd21/platform.yml b/eosim/platforms/samd21/platform.yml similarity index 100% rename from platforms/samd21/platform.yml rename to eosim/platforms/samd21/platform.yml diff --git a/platforms/samd51/platform.yml b/eosim/platforms/samd51/platform.yml similarity index 100% rename from platforms/samd51/platform.yml rename to eosim/platforms/samd51/platform.yml diff --git a/platforms/samd51/tests.yml b/eosim/platforms/samd51/tests.yml similarity index 100% rename from platforms/samd51/tests.yml rename to eosim/platforms/samd51/tests.yml diff --git a/platforms/same70/platform.yml b/eosim/platforms/same70/platform.yml similarity index 100% rename from platforms/same70/platform.yml rename to eosim/platforms/same70/platform.yml diff --git a/platforms/saml21/platform.yml b/eosim/platforms/saml21/platform.yml similarity index 100% rename from platforms/saml21/platform.yml rename to eosim/platforms/saml21/platform.yml diff --git a/platforms/samsung-exynos-auto-v9/platform.yml b/eosim/platforms/samsung-exynos-auto-v9/platform.yml similarity index 100% rename from platforms/samsung-exynos-auto-v9/platform.yml rename to eosim/platforms/samsung-exynos-auto-v9/platform.yml diff --git a/platforms/si7021/platform.yml b/eosim/platforms/si7021/platform.yml similarity index 100% rename from platforms/si7021/platform.yml rename to eosim/platforms/si7021/platform.yml diff --git a/platforms/sifive_u/platform.yml b/eosim/platforms/sifive_u/platform.yml similarity index 100% rename from platforms/sifive_u/platform.yml rename to eosim/platforms/sifive_u/platform.yml diff --git a/platforms/sifive_u/tests.yml b/eosim/platforms/sifive_u/tests.yml similarity index 100% rename from platforms/sifive_u/tests.yml rename to eosim/platforms/sifive_u/tests.yml diff --git a/platforms/smart-city-sim/platform.yml b/eosim/platforms/smart-city-sim/platform.yml similarity index 100% rename from platforms/smart-city-sim/platform.yml rename to eosim/platforms/smart-city-sim/platform.yml diff --git a/platforms/starfive-jh7110/platform.yml b/eosim/platforms/starfive-jh7110/platform.yml similarity index 100% rename from platforms/starfive-jh7110/platform.yml rename to eosim/platforms/starfive-jh7110/platform.yml diff --git a/platforms/steamdeck-embedded/platform.yml b/eosim/platforms/steamdeck-embedded/platform.yml similarity index 100% rename from platforms/steamdeck-embedded/platform.yml rename to eosim/platforms/steamdeck-embedded/platform.yml diff --git a/platforms/stm32f4/platform.yml b/eosim/platforms/stm32f4/platform.yml similarity index 100% rename from platforms/stm32f4/platform.yml rename to eosim/platforms/stm32f4/platform.yml diff --git a/platforms/stm32f4/tests.yml b/eosim/platforms/stm32f4/tests.yml similarity index 100% rename from platforms/stm32f4/tests.yml rename to eosim/platforms/stm32f4/tests.yml diff --git a/platforms/stm32h7/platform.yml b/eosim/platforms/stm32h7/platform.yml similarity index 100% rename from platforms/stm32h7/platform.yml rename to eosim/platforms/stm32h7/platform.yml diff --git a/platforms/stm32h7/tests.yml b/eosim/platforms/stm32h7/tests.yml similarity index 100% rename from platforms/stm32h7/tests.yml rename to eosim/platforms/stm32h7/tests.yml diff --git a/platforms/stm32l4/platform.yml b/eosim/platforms/stm32l4/platform.yml similarity index 100% rename from platforms/stm32l4/platform.yml rename to eosim/platforms/stm32l4/platform.yml diff --git a/platforms/stm32l4/tests.yml b/eosim/platforms/stm32l4/tests.yml similarity index 100% rename from platforms/stm32l4/tests.yml rename to eosim/platforms/stm32l4/tests.yml diff --git a/platforms/stm32mp1/platform.yml b/eosim/platforms/stm32mp1/platform.yml similarity index 100% rename from platforms/stm32mp1/platform.yml rename to eosim/platforms/stm32mp1/platform.yml diff --git a/platforms/stm32mp1/tests.yml b/eosim/platforms/stm32mp1/tests.yml similarity index 100% rename from platforms/stm32mp1/tests.yml rename to eosim/platforms/stm32mp1/tests.yml diff --git a/platforms/switch-embedded/platform.yml b/eosim/platforms/switch-embedded/platform.yml similarity index 100% rename from platforms/switch-embedded/platform.yml rename to eosim/platforms/switch-embedded/platform.yml diff --git a/platforms/tda4vm/platform.yml b/eosim/platforms/tda4vm/platform.yml similarity index 100% rename from platforms/tda4vm/platform.yml rename to eosim/platforms/tda4vm/platform.yml diff --git a/platforms/templates/arm64-board.yml b/eosim/platforms/templates/arm64-board.yml similarity index 100% rename from platforms/templates/arm64-board.yml rename to eosim/platforms/templates/arm64-board.yml diff --git a/platforms/templates/mcu-board.yml b/eosim/platforms/templates/mcu-board.yml similarity index 100% rename from platforms/templates/mcu-board.yml rename to eosim/platforms/templates/mcu-board.yml diff --git a/platforms/ti-msp432/platform.yml b/eosim/platforms/ti-msp432/platform.yml similarity index 100% rename from platforms/ti-msp432/platform.yml rename to eosim/platforms/ti-msp432/platform.yml diff --git a/platforms/ti-tda4vm/platform.yml b/eosim/platforms/ti-tda4vm/platform.yml similarity index 100% rename from platforms/ti-tda4vm/platform.yml rename to eosim/platforms/ti-tda4vm/platform.yml diff --git a/platforms/ti-tms570/platform.yml b/eosim/platforms/ti-tms570/platform.yml similarity index 100% rename from platforms/ti-tms570/platform.yml rename to eosim/platforms/ti-tms570/platform.yml diff --git a/platforms/tizen-tv/platform.yml b/eosim/platforms/tizen-tv/platform.yml similarity index 100% rename from platforms/tizen-tv/platform.yml rename to eosim/platforms/tizen-tv/platform.yml diff --git a/platforms/tms320/platform.yml b/eosim/platforms/tms320/platform.yml similarity index 100% rename from platforms/tms320/platform.yml rename to eosim/platforms/tms320/platform.yml diff --git a/platforms/ubiquiti-edgerouter/platform.yml b/eosim/platforms/ubiquiti-edgerouter/platform.yml similarity index 100% rename from platforms/ubiquiti-edgerouter/platform.yml rename to eosim/platforms/ubiquiti-edgerouter/platform.yml diff --git a/platforms/versatilepb/platform.yml b/eosim/platforms/versatilepb/platform.yml similarity index 100% rename from platforms/versatilepb/platform.yml rename to eosim/platforms/versatilepb/platform.yml diff --git a/platforms/versatilepb/tests.yml b/eosim/platforms/versatilepb/tests.yml similarity index 100% rename from platforms/versatilepb/tests.yml rename to eosim/platforms/versatilepb/tests.yml diff --git a/platforms/vexpress-a15/platform.yml b/eosim/platforms/vexpress-a15/platform.yml similarity index 100% rename from platforms/vexpress-a15/platform.yml rename to eosim/platforms/vexpress-a15/platform.yml diff --git a/platforms/vexpress-a15/tests.yml b/eosim/platforms/vexpress-a15/tests.yml similarity index 100% rename from platforms/vexpress-a15/tests.yml rename to eosim/platforms/vexpress-a15/tests.yml diff --git a/platforms/vexpress-a9/platform.yml b/eosim/platforms/vexpress-a9/platform.yml similarity index 100% rename from platforms/vexpress-a9/platform.yml rename to eosim/platforms/vexpress-a9/platform.yml diff --git a/platforms/vexpress-a9/tests.yml b/eosim/platforms/vexpress-a9/tests.yml similarity index 100% rename from platforms/vexpress-a9/tests.yml rename to eosim/platforms/vexpress-a9/tests.yml diff --git a/platforms/weather-sim/platform.yml b/eosim/platforms/weather-sim/platform.yml similarity index 100% rename from platforms/weather-sim/platform.yml rename to eosim/platforms/weather-sim/platform.yml diff --git a/platforms/weather-sim/tests.yml b/eosim/platforms/weather-sim/tests.yml similarity index 100% rename from platforms/weather-sim/tests.yml rename to eosim/platforms/weather-sim/tests.yml diff --git a/platforms/webos-tv/platform.yml b/eosim/platforms/webos-tv/platform.yml similarity index 100% rename from platforms/webos-tv/platform.yml rename to eosim/platforms/webos-tv/platform.yml diff --git a/platforms/x86_64/platform.yml b/eosim/platforms/x86_64/platform.yml similarity index 100% rename from platforms/x86_64/platform.yml rename to eosim/platforms/x86_64/platform.yml diff --git a/platforms/x86_64/tests.yml b/eosim/platforms/x86_64/tests.yml similarity index 100% rename from platforms/x86_64/tests.yml rename to eosim/platforms/x86_64/tests.yml diff --git a/platforms/xilinx-versal-auto/platform.yml b/eosim/platforms/xilinx-versal-auto/platform.yml similarity index 100% rename from platforms/xilinx-versal-auto/platform.yml rename to eosim/platforms/xilinx-versal-auto/platform.yml diff --git a/platforms/xilinx-versal/platform.yml b/eosim/platforms/xilinx-versal/platform.yml similarity index 100% rename from platforms/xilinx-versal/platform.yml rename to eosim/platforms/xilinx-versal/platform.yml diff --git a/platforms/xilinx-zynq7020/platform.yml b/eosim/platforms/xilinx-zynq7020/platform.yml similarity index 100% rename from platforms/xilinx-zynq7020/platform.yml rename to eosim/platforms/xilinx-zynq7020/platform.yml diff --git a/platforms/xtensa-esp/platform.yml b/eosim/platforms/xtensa-esp/platform.yml similarity index 100% rename from platforms/xtensa-esp/platform.yml rename to eosim/platforms/xtensa-esp/platform.yml diff --git a/platforms/xtensa-esp/tests.yml b/eosim/platforms/xtensa-esp/tests.yml similarity index 100% rename from platforms/xtensa-esp/tests.yml rename to eosim/platforms/xtensa-esp/tests.yml diff --git a/out/logs/stm32f4.log b/out/logs/stm32f4.log new file mode 100644 index 0000000..64ab83a --- /dev/null +++ b/out/logs/stm32f4.log @@ -0,0 +1,5 @@ +=== EoSim Native Log === +Platform: stm32f4 +Arch: arm + +EoSim Virtual Machine: stm32f4 (arm)\nRAM: 64 MB | Peripherals: 6\nBooting...\n\nSimulation complete: 10000 cycles in 0.008s\nEoS booted successfully\n \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 463e0fb..fda2988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "eosim" version = "3.0.1" -description = "World's most powerful universal simulation platform — supersedes 250+ tools across 20 domains" +description = "Multi-architecture embedded simulation platform for EoS — native engine plus Renode/QEMU backends" readme = "README.md" requires-python = ">=3.9" license = {text = "MIT"} @@ -16,7 +16,7 @@ maintainers = [ {name = "EoS Project", email = "team@embeddedos.org"}, ] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", @@ -66,6 +66,12 @@ eosim = "eosim.cli.main:cli" [tool.setuptools.packages.find] include = ["eosim*"] +# The platform registry is data, not modules, so packages.find alone does not +# ship it. Without this the wheel installs but `eosim run` cannot find any +# platform - see _resolve_platforms_dir() in eosim/cli/main.py. +[tool.setuptools.package-data] +"eosim.platforms" = ["**/*.yml", "**/*.yaml", "**/*.resc", "**/*.repl"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/tests/integration/test_platform_pipeline.py b/tests/integration/test_platform_pipeline.py index ff16ab7..0c7d5d0 100644 --- a/tests/integration/test_platform_pipeline.py +++ b/tests/integration/test_platform_pipeline.py @@ -89,7 +89,7 @@ class TestPlatformValidation: def test_validate_all_real_platforms(self): """Validate all platform.yml files in the platforms/ directory.""" - platforms_dir = Path(__file__).parent.parent.parent / "platforms" + platforms_dir = Path(__file__).parent.parent.parent / "eosim" / "platforms" if not platforms_dir.exists(): pytest.skip("platforms/ directory not found") @@ -107,7 +107,7 @@ def test_validate_all_real_platforms(self): def test_discover_real_platforms(self): """Verify discover_platforms can load the real platforms/ directory.""" - platforms_dir = Path(__file__).parent.parent.parent / "platforms" + platforms_dir = Path(__file__).parent.parent.parent / "eosim" / "platforms" if not platforms_dir.exists(): pytest.skip("platforms/ directory not found") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 5f917ee..01b0c1c 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,256 +1,279 @@ -# SPDX-License-Identifier: MIT -"""Unit tests for CLI commands using Click's CliRunner.""" -from unittest.mock import patch, MagicMock -from click.testing import CliRunner - - -class TestCLIList: - def test_list_runs(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list']) - assert result.exit_code == 0 - assert 'Available platforms' in result.output - - def test_list_json(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list', '--format', 'json']) - assert result.exit_code == 0 - assert '[' in result.output # JSON array - - def test_list_csv(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list', '--format', 'csv']) - assert result.exit_code == 0 - assert 'name,arch,engine' in result.output - - def test_list_filter_domain(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list', '--domain', 'automotive']) - assert result.exit_code == 0 - - def test_list_filter_arch(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list', '--arch', 'arm64']) - assert result.exit_code == 0 - - def test_list_group_by(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['list', '--group-by', 'arch']) - assert result.exit_code == 0 - - -class TestCLISearch: - def test_search_found(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['search', 'stm32']) - assert result.exit_code == 0 - assert 'stm32' in result.output.lower() - - def test_search_not_found(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['search', 'zzzznonexistent']) - assert result.exit_code == 0 - assert 'No platforms' in result.output - - -class TestCLIStats: - def test_stats(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['stats']) - assert result.exit_code == 0 - assert 'Platform Statistics' in result.output - - -class TestCLIInfo: - def test_info_existing(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['info', 'esp32']) - assert result.exit_code == 0 - assert 'esp32' in result.output.lower() - - def test_info_missing(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['info', 'nonexistent_platform_xyz']) - assert result.exit_code != 0 - - -class TestCLIValidate: - def test_validate_all(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['validate', '--all']) - assert result.exit_code == 0 - assert 'Validated' in result.output - assert 'passed' in result.output - - def test_validate_no_arg(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['validate']) - assert result.exit_code != 0 - - def test_validate_missing_file(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['validate', '/nonexistent/file.yml']) - assert result.exit_code != 0 - - -class TestCLIDoctor: - def test_doctor(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['doctor']) - assert result.exit_code == 0 - assert 'EoSim Doctor' in result.output - assert 'Platform Registry' in result.output - - -class TestCLIDomain: - def test_domain_list(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['domain', 'list']) - assert result.exit_code == 0 - assert 'Simulation Domains' in result.output - assert 'automotive' in result.output.lower() - - def test_domain_info(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['domain', 'info', 'automotive']) - assert result.exit_code == 0 - assert 'ISO 26262' in result.output - - def test_domain_info_missing(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['domain', 'info', 'nonexistent']) - assert result.exit_code != 0 - - -class TestCLIModeling: - def test_modeling_list(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['modeling', 'list']) - assert result.exit_code == 0 - assert 'Modeling Methods' in result.output - - def test_modeling_info(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['modeling', 'info', 'deterministic']) - assert result.exit_code == 0 - assert 'Deterministic' in result.output - - def test_modeling_info_missing(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['modeling', 'info', 'nonexistent']) - assert result.exit_code != 0 - - -class TestCLIBridge: - def test_bridge_status(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['bridge', 'status']) - assert result.exit_code == 0 - assert 'Bridge Status' in result.output - - -class TestCLISimulator: - def test_simulator_list(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['simulator', 'list']) - assert result.exit_code == 0 - assert 'Available Simulators' in result.output - - def test_simulator_products(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['simulator', 'products']) - assert result.exit_code == 0 - assert 'Product Templates' in result.output - - def test_simulator_run(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['simulator', 'run', 'vehicle', '--ticks', '10']) - assert result.exit_code == 0 - assert 'Tick' in result.output - - def test_simulator_run_with_scenario(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['simulator', 'run', 'vehicle', '--ticks', '10', '--scenario', 'highway_cruise']) - assert result.exit_code == 0 - assert 'highway_cruise' in result.output - - def test_simulator_run_unknown(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['simulator', 'run', 'nonexistent_xyz']) - assert result.exit_code != 0 - - -class TestCLIVersion: - def test_version(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['--version']) - assert result.exit_code == 0 - assert '2.0.0' in result.output - - -class TestCLIRun: - def test_run_eosim_engine(self, tmp_path): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['run', 'esp32', '--timeout', '5', - '--log-dir', str(tmp_path)]) - # Should succeed (eosim native engine) - assert result.exit_code == 0 - - def test_run_missing_platform(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['run', 'nonexistent_xyz']) - assert result.exit_code != 0 - - -class TestCLITest: - def test_test_command(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['test', 'esp32']) - assert result.exit_code == 0 - assert 'EoSim test' in result.output - - def test_test_missing(self): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['test', 'nonexistent_xyz']) - assert result.exit_code != 0 - - -class TestCLIArtifact: - def test_artifact(self, tmp_path): - from eosim.cli.main import cli - runner = CliRunner() - result = runner.invoke(cli, ['artifact', 'esp32', '--output', str(tmp_path)]) - assert result.exit_code == 0 - assert 'Artifacts exported' in result.output +# SPDX-License-Identifier: MIT +"""Unit tests for CLI commands using Click's CliRunner.""" +from unittest.mock import patch, MagicMock +from click.testing import CliRunner + + +class TestCLIList: + def test_list_runs(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list']) + assert result.exit_code == 0 + assert 'Available platforms' in result.output + + def test_list_json(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list', '--format', 'json']) + assert result.exit_code == 0 + assert '[' in result.output # JSON array + + def test_list_csv(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list', '--format', 'csv']) + assert result.exit_code == 0 + assert 'name,arch,engine' in result.output + + def test_list_filter_domain(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list', '--domain', 'automotive']) + assert result.exit_code == 0 + + def test_list_filter_arch(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list', '--arch', 'arm64']) + assert result.exit_code == 0 + + def test_list_group_by(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['list', '--group-by', 'arch']) + assert result.exit_code == 0 + + +class TestCLISearch: + def test_search_found(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['search', 'stm32']) + assert result.exit_code == 0 + assert 'stm32' in result.output.lower() + + def test_search_not_found(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['search', 'zzzznonexistent']) + assert result.exit_code == 0 + assert 'No platforms' in result.output + + +class TestCLIStats: + def test_stats(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['stats']) + assert result.exit_code == 0 + assert 'Platform Statistics' in result.output + + +class TestCLIInfo: + def test_info_existing(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['info', 'esp32']) + assert result.exit_code == 0 + assert 'esp32' in result.output.lower() + + def test_info_missing(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['info', 'nonexistent_platform_xyz']) + assert result.exit_code != 0 + + +class TestCLIValidate: + def test_validate_all(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['validate', '--all']) + assert result.exit_code == 0 + assert 'Validated' in result.output + assert 'passed' in result.output + + def test_validate_no_arg(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['validate']) + assert result.exit_code != 0 + + def test_validate_missing_file(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['validate', '/nonexistent/file.yml']) + assert result.exit_code != 0 + + +class TestCLIDoctor: + def test_doctor(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['doctor']) + assert result.exit_code == 0 + assert 'EoSim Doctor' in result.output + assert 'Platform Registry' in result.output + + +class TestCLIDomain: + def test_domain_list(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['domain', 'list']) + assert result.exit_code == 0 + assert 'Simulation Domains' in result.output + assert 'automotive' in result.output.lower() + + def test_domain_info(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['domain', 'info', 'automotive']) + assert result.exit_code == 0 + assert 'ISO 26262' in result.output + + def test_domain_info_missing(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['domain', 'info', 'nonexistent']) + assert result.exit_code != 0 + + +class TestCLIModeling: + def test_modeling_list(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['modeling', 'list']) + assert result.exit_code == 0 + assert 'Modeling Methods' in result.output + + def test_modeling_info(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['modeling', 'info', 'deterministic']) + assert result.exit_code == 0 + assert 'Deterministic' in result.output + + def test_modeling_info_missing(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['modeling', 'info', 'nonexistent']) + assert result.exit_code != 0 + + +class TestCLIBridge: + def test_bridge_status(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['bridge', 'status']) + assert result.exit_code == 0 + assert 'Bridge Status' in result.output + + +class TestCLISimulator: + def test_simulator_list(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['simulator', 'list']) + assert result.exit_code == 0 + assert 'Available Simulators' in result.output + + def test_simulator_products(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['simulator', 'products']) + assert result.exit_code == 0 + assert 'Product Templates' in result.output + + def test_simulator_run(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['simulator', 'run', 'vehicle', '--ticks', '10']) + assert result.exit_code == 0 + assert 'Tick' in result.output + + def test_simulator_run_with_scenario(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['simulator', 'run', 'vehicle', '--ticks', '10', '--scenario', 'highway_cruise']) + assert result.exit_code == 0 + assert 'highway_cruise' in result.output + + def test_simulator_run_unknown(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['simulator', 'run', 'nonexistent_xyz']) + assert result.exit_code != 0 + + +class TestCLIVersion: + def test_version(self): + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['--version']) + assert result.exit_code == 0 + # Pinned to the package version rather than a literal: the CLI hardcoded + # 2.0.0 while pyproject/eosim.__version__ said 3.0.1. + from eosim import __version__ + assert __version__ in result.output + + +class TestCLIRun: + def test_run_eosim_engine_without_firmware_exits_2(self, tmp_path): + """`eosim run ` with no image executes nothing. + + This asserted exit_code == 0, which is why `eosim run stm32f4` printed + "PASSED (10000 cycles)" while stepping over zeroed memory. + """ + from eosim.cli.main import cli + runner = CliRunner() + result = runner.invoke(cli, ['run', 'esp32', '--timeout', '5', + '--log-dir', str(tmp_path)]) + assert result.exit_code == 2 + assert 'NO FIRMWARE' in result.output + + def test_run_eosim_engine_with_firmware(self, tmp_path): + """The same command with --firmware runs the image to its halt.""" + import struct + + from eosim.cli.main import cli + img = tmp_path / 'fw.bin' + img.write_bytes(b''.join(struct.pack('= 4 - assert "arm64-linux" in platforms - assert "riscv64-linux" in platforms - assert "x86_64-linux" in platforms - - def test_discover_empty(self, tmp_path): - platforms = discover_platforms(str(tmp_path)) - assert len(platforms) == 0 - - def test_defaults(self): - p = Platform() - assert p.runtime.memory_mb == 512 - assert p.runtime.headless is True - assert p.engine == "renode" - -class TestSimResult: - def test_defaults(self): - r = SimResult() - assert r.success is False - assert r.exit_code == -1 - assert r.artifacts == [] - - def test_boot_detection(self): - r = SimResult(stdout="kernel booted successfully login:") - r.boot_detected = "login:" in r.stdout - assert r.boot_detected is True - -class TestRunner: - def test_serial_contains_pass(self): - r = SimResult(stdout="Welcome to EoS login: root") - checks = [{"type": "serial_contains", "value": "login:"}] - results = run_checks(r, checks) - assert len(results) == 1 - assert results[0].passed is True - - def test_serial_contains_fail(self): - r = SimResult(stdout="kernel panic") - checks = [{"type": "serial_contains", "value": "login:"}] - results = run_checks(r, checks) - assert results[0].passed is False - - def test_timeout_check(self): - r = SimResult(duration_s=30.0) - checks = [{"type": "timeout", "seconds": 60}] - results = run_checks(r, checks) - assert results[0].passed is True - - def test_boot_success(self): - r = SimResult(boot_detected=True) - checks = [{"type": "boot_success"}] - results = run_checks(r, checks) - assert results[0].passed is True - - def test_multiple_checks(self): - r = SimResult(stdout="EoS booted login:", duration_s=10, boot_detected=True) - checks = [ - {"type": "serial_contains", "value": "login:"}, - {"type": "timeout", "seconds": 60}, - {"type": "boot_success"}, - ] - results = run_checks(r, checks) - assert all(t.passed for t in results) - assert len(results) == 3 - -class TestArtifacts: - def test_collect(self, tmp_path): - log = tmp_path / "test.log" - log.write_text("boot output") - r = SimResult(platform="test", engine="qemu", success=True, - duration_s=5.0, log_file=str(log)) - manifest = collect_artifacts(r, str(tmp_path / "artifacts")) - assert manifest["success"] is True - assert manifest["platform"] == "test" - - def test_junit(self, tmp_path): - results = [ - {"platform": "arm64", "success": True, "duration_s": 5.0}, - {"platform": "riscv", "success": False, "duration_s": 3.0}, - ] - output = str(tmp_path / "junit.xml") - path = generate_junit(results, output) - assert os.path.exists(path) - content = open(path).read() - assert 'tests="2"' in content - assert 'failures="1"' in content - -class TestEngines: - def test_renode_available(self): - RenodeEngine.available() - - def test_qemu_available(self): - QemuEngine.available("x86_64") - -class TestSchema: - def test_valid_platform(self): - from eosim.core.schema import validate_platform - data = {"name": "test", "arch": "arm64", "engine": "renode"} - assert validate_platform(data) == [] - - def test_missing_fields(self): - from eosim.core.schema import validate_platform - errors = validate_platform({}) - assert len(errors) == 3 - - def test_invalid_arch(self): - from eosim.core.schema import validate_platform - errors = validate_platform({"name": "t", "arch": "z80", "engine": "qemu"}) - assert any("invalid arch" in e for e in errors) - - def test_invalid_engine(self): - from eosim.core.schema import validate_platform - errors = validate_platform({"name": "t", "arch": "arm64", "engine": "bochs"}) - assert any("invalid engine" in e for e in errors) - - -class TestScenarios: - def test_wait_for_pass(self): - from eosim.tests.scenarios import run_scenario - scenario = {"steps": [{"type": "wait_for", "pattern": "login:"}]} - results = run_scenario(scenario, "Welcome login: root", 5.0) - assert results[0].passed - - def test_assert_no_pass(self): - from eosim.tests.scenarios import run_scenario - scenario = {"steps": [{"type": "assert_no", "pattern": "panic"}]} - results = run_scenario(scenario, "Booting Linux", 5.0) - assert results[0].passed - - def test_assert_no_fail(self): - from eosim.tests.scenarios import run_scenario - scenario = {"steps": [{"type": "assert_no", "pattern": "panic"}]} - results = run_scenario(scenario, "kernel panic", 5.0) - assert not results[0].passed - - def test_count_matches(self): - from eosim.tests.scenarios import run_scenario - scenario = {"steps": [{"type": "count_matches", "pattern": "OK", "min_count": 3}]} - results = run_scenario(scenario, "OK OK OK OK", 5.0) - assert results[0].passed - - -class TestCluster: - def test_from_yaml(self, tmp_path): - from eosim.core.cluster import Cluster - cfg = tmp_path / "cluster.yml" - cfg.write_text("name: test\nnodes:\n - name: n1\n platform: arm64-linux\nlinks: []") - c = Cluster.from_yaml(str(cfg)) - assert c.name == "test" - assert len(c.nodes) == 1 - - def test_validate_ok(self): - from eosim.core.cluster import Cluster, ClusterNode - c = Cluster(name="test", nodes=[ClusterNode(name="n1", platform="arm64-linux")]) - errors = c.validate({"arm64-linux": None}) - assert len(errors) == 0 - - def test_validate_duplicate_node(self): - from eosim.core.cluster import Cluster, ClusterNode - c = Cluster(name="test", nodes=[ - ClusterNode(name="n1", platform="arm64-linux"), - ClusterNode(name="n1", platform="arm64-linux")]) - errors = c.validate({"arm64-linux": None}) - assert any("duplicate" in e for e in errors) - - -class TestPeripherals: - def test_list(self): - from eosim.engine.peripherals import list_peripherals - perips = list_peripherals() - assert "uart" in perips - assert "spi" in perips - assert "gpio" in perips - assert len(perips) >= 8 - - def test_generate_repl(self): - from eosim.engine.peripherals import generate_repl_peripherals - repl = generate_repl_peripherals(["uart", "spi"]) - assert "UART" in repl - assert "SPI" in repl - - -class TestJobQueue: - def test_submit_and_get(self, tmp_path): - from eosim.core.jobs import JobQueue - q = JobQueue(str(tmp_path)) - job = q.submit("arm64-linux", "qemu") - assert job.status == "pending" - got = q.get(job.job_id) - assert got is not None - assert got.platform == "arm64-linux" - - def test_list_jobs(self, tmp_path): - import time - - from eosim.core.jobs import JobQueue - q = JobQueue(str(tmp_path)) - q.submit("arm64-linux") - time.sleep(0.01) - q.submit("riscv64-linux") - jobs = q.list_jobs() - assert len(jobs) >= 1 - - def test_update_status(self, tmp_path): - from eosim.core.jobs import JobQueue - q = JobQueue(str(tmp_path)) - job = q.submit("arm64-linux") - q.update(job.job_id, status="completed") - updated = q.get(job.job_id) - assert updated.status == "completed" - -class TestNativeEngine: - def test_vm_create(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine('test', 'arm64', ram_mb=64) - assert vm.name == 'test' - assert vm.arch == 'arm64' - assert len(vm.peripherals) >= 5 - - def test_vm_run(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine('test', 'arm', ram_mb=32) - result = vm.run(max_cycles=100, timeout_s=5) - assert result['success'] - assert result['cycles'] > 0 - assert 'EoSim' in result['boot_log'] - - def test_memory(self): - from eosim.engine.native.memory import MemoryBus, MemoryRegion - bus = MemoryBus() - bus.add_region(MemoryRegion('ram', 0x20000000, 1024)) - bus.write32(0x20000000, 0xDEADBEEF) - assert bus.read32(0x20000000) == 0xDEADBEEF - bus.write8(0x20000010, 0x42) - assert bus.read8(0x20000010) == 0x42 - - def test_uart(self): - from eosim.engine.native.peripherals import UARTDevice - uart = UARTDevice('uart0', 0x40000000) - uart.write_reg(0x00, ord('H')) - uart.write_reg(0x00, ord('i')) - assert uart.get_output() == 'Hi' - - def test_gpio(self): - from eosim.engine.native.peripherals import GPIODevice - gpio = GPIODevice('gpio0', 0x40010000) - gpio.write_reg(0x00, 0xFF) # direction = output - gpio.write_reg(0x04, 0x55) # output value - assert gpio.read_reg(0x04) == 0x55 - gpio.set_input(0, True) - assert gpio.read_reg(0x08) & 1 == 1 - - def test_timer(self): - from eosim.engine.native.peripherals import TimerDevice - timer = TimerDevice('timer0', 0x40020000) - timer.write_reg(0x00, 10) # reload = 10 - timer.write_reg(0x04, 3) # enable + irq - for _ in range(10): - timer.tick() - assert timer.irq_pending - - def test_spi(self): - from eosim.engine.native.peripherals import SPIDevice - spi = SPIDevice('spi0', 0x40030000) - spi.write_reg(0x04, 1) # enable - spi.write_reg(0x00, 0xAA) # tx - assert spi.read_reg(0x04) & 2 # transfer complete - - def test_i2c(self): - from eosim.engine.native.peripherals import I2CDevice - i2c = I2CDevice('i2c0', 0x40040000) - i2c.add_slave(0x50, lambda v: 0x42) - i2c.write_reg(0x00, 0x50) # slave addr - i2c.write_reg(0x04, 0x01) # tx - assert i2c.read_reg(0x04) == 0x42 # rx from slave - assert i2c.read_reg(0x08) == 1 # ACK - - def test_interrupt_controller(self): - from eosim.engine.native.peripherals import InterruptController - nvic = InterruptController('nvic', 0xE000E000) - nvic.enable_irq(5) - nvic.trigger(5) - assert nvic.get_highest_pending() == 5 - nvic.acknowledge(5) - assert nvic.get_highest_pending() == -1 - - def test_cpu_state(self): - from eosim.engine.native.cpu import CPUSimulator - cpu = CPUSimulator('arm64') - cpu.reset(entry=0x08000000, stack=0x20001000) - assert cpu.state.pc == 0x08000000 - assert cpu.state.sp == 0x20001000 - - def test_eosim_engine(self): - from eosim.engine.backend import EoSimEngine - assert EoSimEngine.available() - - -class TestPlatformExtendedFields: - """Tests for new Platform metadata fields.""" - - def test_from_yaml_with_metadata(self, tmp_path): - cfg = tmp_path / "platform.yml" - cfg.write_text(yaml.dump({ - "name": "test-mcu", "arch": "arm", "engine": "eosim", - "vendor": "ST", "class": "mcu", "soc": "STM32F407", - "domain": "industrial", "modeling": "deterministic", - "domain_config": {"safety_level": "SIL-2"}, - "modeling_config": {"seed": 42}, - "runtime": {"memory_mb": 128}, - })) - p = Platform.from_yaml(str(cfg)) - assert p.vendor == "ST" - assert p.platform_class == "mcu" - assert p.soc == "STM32F407" - assert p.domain == "industrial" - assert p.modeling == "deterministic" - assert p.domain_config == {"safety_level": "SIL-2"} - assert p.modeling_config == {"seed": 42} - - def test_from_yaml_without_metadata(self, tmp_path): - cfg = tmp_path / "platform.yml" - cfg.write_text(yaml.dump({"name": "bare", "arch": "arm64", "engine": "qemu"})) - p = Platform.from_yaml(str(cfg)) - assert p.vendor == "" - assert p.platform_class == "" - assert p.soc == "" - assert p.domain == "" - assert p.modeling == "" - assert p.domain_config == {} - assert p.modeling_config == {} - - def test_backward_compat_defaults(self): - p = Platform() - assert p.vendor == "" - assert p.platform_class == "" - assert p.soc == "" - assert p.domain == "" - assert p.modeling == "" - - def test_class_keyword_mapping(self, tmp_path): - cfg = tmp_path / "platform.yml" - cfg.write_text(yaml.dump({ - "name": "t", "arch": "arm", "engine": "eosim", "class": "sbc", - })) - p = Platform.from_yaml(str(cfg)) - assert p.platform_class == "sbc" - - -class TestRegistry: - """Tests for PlatformRegistry filter, group, search, stats.""" - - def _make_registry(self): - from eosim.core.registry import PlatformRegistry - p1 = Platform(name="stm32f4", arch="arm", engine="eosim", - vendor="ST", platform_class="mcu", soc="STM32F407", - domain="industrial") - p2 = Platform(name="nrf52", arch="arm", engine="eosim", - vendor="Nordic", platform_class="mcu", soc="nRF52840", - domain="iot") - p3 = Platform(name="raspi4", arch="arm64", engine="renode", - vendor="Raspberry Pi", platform_class="sbc", soc="BCM2711", - domain="consumer") - p4 = Platform(name="esp32", arch="xtensa", engine="eosim", - vendor="Espressif", platform_class="mcu", soc="ESP32", - domain="iot") - return PlatformRegistry.from_dict({ - "stm32f4": p1, "nrf52": p2, "raspi4": p3, "esp32": p4, - }) - - def test_count(self): - reg = self._make_registry() - assert reg.count() == 4 - - def test_all(self): - reg = self._make_registry() - assert len(reg.all()) == 4 - - def test_get_existing(self): - reg = self._make_registry() - p = reg.get("stm32f4") - assert p is not None - assert p.vendor == "ST" - - def test_get_missing(self): - reg = self._make_registry() - assert reg.get("nonexistent") is None - - def test_filter_by_arch(self): - reg = self._make_registry() - results = reg.filter(arch="arm") - assert len(results) == 2 - assert all(p.arch == "arm" for p in results) - - def test_filter_by_vendor(self): - reg = self._make_registry() - results = reg.filter(vendor="ST") - assert len(results) == 1 - assert results[0].name == "stm32f4" - - def test_filter_by_class(self): - reg = self._make_registry() - results = reg.filter(platform_class="mcu") - assert len(results) == 3 - - def test_filter_by_engine(self): - reg = self._make_registry() - results = reg.filter(engine="eosim") - assert len(results) == 3 - - def test_filter_by_domain(self): - reg = self._make_registry() - results = reg.filter(domain="iot") - assert len(results) == 2 - - def test_filter_combined(self): - reg = self._make_registry() - results = reg.filter(arch="arm", vendor="Nordic") - assert len(results) == 1 - assert results[0].name == "nrf52" - - def test_filter_no_results(self): - reg = self._make_registry() - results = reg.filter(arch="mipsel", vendor="ST") - assert len(results) == 0 - - def test_filter_case_insensitive(self): - reg = self._make_registry() - results = reg.filter(vendor="st") - assert len(results) == 1 - - def test_group_by_arch(self): - reg = self._make_registry() - groups = reg.group_by("arch") - assert "arm" in groups - assert "arm64" in groups - assert len(groups["arm"]) == 2 - - def test_group_by_vendor(self): - reg = self._make_registry() - groups = reg.group_by("vendor") - assert "ST" in groups - assert "Nordic" in groups - - def test_search(self): - reg = self._make_registry() - results = reg.search("STM32") - assert len(results) == 1 - assert results[0].name == "stm32f4" - - def test_search_case_insensitive(self): - reg = self._make_registry() - results = reg.search("espressif") - assert len(results) == 1 - assert results[0].name == "esp32" - - def test_search_by_arch(self): - reg = self._make_registry() - results = reg.search("xtensa") - assert len(results) >= 1 - - def test_stats(self): - reg = self._make_registry() - st = reg.stats() - assert "arch" in st - assert "vendor" in st - assert "platform_class" in st - assert "engine" in st - assert st["arch"]["arm"] == 2 - - def test_vendors(self): - reg = self._make_registry() - vendors = reg.vendors() - assert "ST" in vendors - assert "Nordic" in vendors - - def test_arches(self): - reg = self._make_registry() - arches = reg.arches() - assert "arm" in arches - assert "arm64" in arches - - def test_classes(self): - reg = self._make_registry() - classes = reg.classes() - assert "mcu" in classes - assert "sbc" in classes - - def test_registry_from_dir(self): - from eosim.core.registry import PlatformRegistry - root = os.path.join(os.path.dirname(__file__), "..", "..", "platforms") - reg = PlatformRegistry(root) - assert reg.count() >= 41 - - -class TestHostEnvironment: - """Tests for HostEnvironment detection and binary resolution.""" - - def test_detect(self): - from eosim.core.host import HostEnvironment - env = HostEnvironment.detect() - assert env.os_name in ("windows", "macos", "linux") - assert env.arch != "" - assert env.python_version != "" - - def test_platform_info(self): - from eosim.core.host import HostEnvironment - env = HostEnvironment.detect() - info = env.platform_info() - assert "os" in info - assert "python" in info - assert "arch" in info - assert "shell" in info - - def test_adapt_path_windows(self): - from eosim.core.host import HostEnvironment - env = HostEnvironment(os_name="windows") - assert "\\" in env.adapt_path("some/path/to/file") - - def test_adapt_path_linux(self): - from eosim.core.host import HostEnvironment - env = HostEnvironment(os_name="linux") - assert "/" in env.adapt_path("some\\path\\to\\file") - - def test_resolve_binary_none(self): - from eosim.core.host import HostEnvironment - env = HostEnvironment.detect() - result = env.resolve_binary("this_binary_definitely_does_not_exist_12345") - assert result is None - - -class TestDomains: - """Tests for domain profiles and catalog.""" - - def test_catalog_complete(self): - from eosim.core.domains import list_domains - domains = list_domains() - assert len(domains) >= 40 - expected = ["automotive", "medical", "industrial", "consumer", - "aerospace", "iot", "robotics", "defense", "energy", "telecom", - "aerodynamics", "physiology", "finance", "weather", "gaming", - "agriculture", "maritime", "nuclear", "railway", "quantum"] - for d in expected: - assert d in domains - - def test_get_domain(self): - from eosim.core.domains import get_domain - d = get_domain("automotive") - assert d is not None - assert d.display_name == "Automotive / Transportation" - assert "ISO 26262" in d.standards - assert len(d.safety_levels) == 4 - - def test_get_domain_missing(self): - from eosim.core.domains import get_domain - assert get_domain("nonexistent") is None - - def test_suggest_platforms(self): - from eosim.core.domains import suggest_platforms - from eosim.core.registry import PlatformRegistry - p1 = Platform(name="auto-mcu", arch="arm", platform_class="mcu", - domain="automotive") - p2 = Platform(name="iot-sensor", arch="xtensa", platform_class="mcu", - domain="iot") - reg = PlatformRegistry.from_dict({"auto-mcu": p1, "iot-sensor": p2}) - results = suggest_platforms("automotive", reg) - assert any(p.name == "auto-mcu" for p in results) - - -class TestModeling: - """Tests for modeling method catalog.""" - - def test_catalog_complete(self): - from eosim.core.modeling import list_modeling_methods - methods = list_modeling_methods() - assert len(methods) >= 20 - expected = ["deterministic", "stochastic", "discrete-event", - "continuous", "hybrid", "agent-based", "cfd", - "monte-carlo", "finite-element", "particle-based"] - for m in expected: - assert m in methods - - def test_get_modeling(self): - from eosim.core.modeling import get_modeling - m = get_modeling("deterministic") - assert m is not None - assert "eosim" in m.engine_support - assert "renode" in m.engine_support - - def test_get_modeling_missing(self): - from eosim.core.modeling import get_modeling - assert get_modeling("nonexistent") is None - - def test_validate_compatible(self): - from eosim.core.modeling import validate_modeling_for_engine - warnings = validate_modeling_for_engine("deterministic", "eosim") - assert len(warnings) == 0 - - def test_validate_incompatible(self): - from eosim.core.modeling import validate_modeling_for_engine - warnings = validate_modeling_for_engine("stochastic", "qemu") - assert len(warnings) > 0 - assert "not supported" in warnings[0] - - def test_validate_unknown_method(self): - from eosim.core.modeling import validate_modeling_for_engine - warnings = validate_modeling_for_engine("unknown_method", "eosim") - assert len(warnings) > 0 - assert "Unknown" in warnings[0] - - -class TestSchemaExtended: - """Tests for extended schema validation (class, domain, modeling).""" - - def test_valid_eosim_engine(self): - from eosim.core.schema import validate_platform - data = {"name": "t", "arch": "arm", "engine": "eosim"} - assert validate_platform(data) == [] - - def test_valid_class(self): - from eosim.core.schema import validate_platform - data = {"name": "t", "arch": "arm", "engine": "eosim", "class": "mcu"} - assert validate_platform(data) == [] - - def test_invalid_class(self): - from eosim.core.schema import validate_platform - errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", - "class": "invalid_class"}) - assert any("invalid class" in e for e in errors) - - def test_valid_domain(self): - from eosim.core.schema import validate_platform - data = {"name": "t", "arch": "arm", "engine": "eosim", - "domain": "automotive"} - assert validate_platform(data) == [] - - def test_invalid_domain(self): - from eosim.core.schema import validate_platform - errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", - "domain": "invalid_domain"}) - assert any("invalid domain" in e for e in errors) - - def test_valid_modeling(self): - from eosim.core.schema import validate_platform - data = {"name": "t", "arch": "arm", "engine": "eosim", - "modeling": "deterministic"} - assert validate_platform(data) == [] - - def test_invalid_modeling(self): - from eosim.core.schema import validate_platform - errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", - "modeling": "invalid_method"}) - assert any("invalid modeling" in e for e in errors) +# SPDX-License-Identifier: MIT +"""Unit tests for EoSim core.""" +import os + +# Minimal ARM image: MOV r0,#1 then UDF (halt). Needed because +# VirtualMachine.run() now refuses to report a boot when no firmware is +# loaded - see tests/unit/test_native_engine_execution.py. +def _tiny_arm_image() -> bytes: + import struct + return b"".join(struct.pack("= 4 + assert "arm64-linux" in platforms + assert "riscv64-linux" in platforms + assert "x86_64-linux" in platforms + + def test_discover_empty(self, tmp_path): + platforms = discover_platforms(str(tmp_path)) + assert len(platforms) == 0 + + def test_defaults(self): + p = Platform() + assert p.runtime.memory_mb == 512 + assert p.runtime.headless is True + assert p.engine == "renode" + +class TestSimResult: + def test_defaults(self): + r = SimResult() + assert r.success is False + assert r.exit_code == -1 + assert r.artifacts == [] + + def test_boot_detection(self): + r = SimResult(stdout="kernel booted successfully login:") + r.boot_detected = "login:" in r.stdout + assert r.boot_detected is True + +class TestRunner: + def test_serial_contains_pass(self): + r = SimResult(stdout="Welcome to EoS login: root") + checks = [{"type": "serial_contains", "value": "login:"}] + results = run_checks(r, checks) + assert len(results) == 1 + assert results[0].passed is True + + def test_serial_contains_fail(self): + r = SimResult(stdout="kernel panic") + checks = [{"type": "serial_contains", "value": "login:"}] + results = run_checks(r, checks) + assert results[0].passed is False + + def test_timeout_check(self): + r = SimResult(duration_s=30.0) + checks = [{"type": "timeout", "seconds": 60}] + results = run_checks(r, checks) + assert results[0].passed is True + + def test_boot_success(self): + r = SimResult(boot_detected=True) + checks = [{"type": "boot_success"}] + results = run_checks(r, checks) + assert results[0].passed is True + + def test_multiple_checks(self): + r = SimResult(stdout="EoS booted login:", duration_s=10, boot_detected=True) + checks = [ + {"type": "serial_contains", "value": "login:"}, + {"type": "timeout", "seconds": 60}, + {"type": "boot_success"}, + ] + results = run_checks(r, checks) + assert all(t.passed for t in results) + assert len(results) == 3 + +class TestArtifacts: + def test_collect(self, tmp_path): + log = tmp_path / "test.log" + log.write_text("boot output") + r = SimResult(platform="test", engine="qemu", success=True, + duration_s=5.0, log_file=str(log)) + manifest = collect_artifacts(r, str(tmp_path / "artifacts")) + assert manifest["success"] is True + assert manifest["platform"] == "test" + + def test_junit(self, tmp_path): + results = [ + {"platform": "arm64", "success": True, "duration_s": 5.0}, + {"platform": "riscv", "success": False, "duration_s": 3.0}, + ] + output = str(tmp_path / "junit.xml") + path = generate_junit(results, output) + assert os.path.exists(path) + content = open(path).read() + assert 'tests="2"' in content + assert 'failures="1"' in content + +class TestEngines: + def test_renode_available(self): + RenodeEngine.available() + + def test_qemu_available(self): + QemuEngine.available("x86_64") + +class TestSchema: + def test_valid_platform(self): + from eosim.core.schema import validate_platform + data = {"name": "test", "arch": "arm64", "engine": "renode"} + assert validate_platform(data) == [] + + def test_missing_fields(self): + from eosim.core.schema import validate_platform + errors = validate_platform({}) + assert len(errors) == 3 + + def test_invalid_arch(self): + from eosim.core.schema import validate_platform + errors = validate_platform({"name": "t", "arch": "z80", "engine": "qemu"}) + assert any("invalid arch" in e for e in errors) + + def test_invalid_engine(self): + from eosim.core.schema import validate_platform + errors = validate_platform({"name": "t", "arch": "arm64", "engine": "bochs"}) + assert any("invalid engine" in e for e in errors) + + +class TestScenarios: + def test_wait_for_pass(self): + from eosim.tests.scenarios import run_scenario + scenario = {"steps": [{"type": "wait_for", "pattern": "login:"}]} + results = run_scenario(scenario, "Welcome login: root", 5.0) + assert results[0].passed + + def test_assert_no_pass(self): + from eosim.tests.scenarios import run_scenario + scenario = {"steps": [{"type": "assert_no", "pattern": "panic"}]} + results = run_scenario(scenario, "Booting Linux", 5.0) + assert results[0].passed + + def test_assert_no_fail(self): + from eosim.tests.scenarios import run_scenario + scenario = {"steps": [{"type": "assert_no", "pattern": "panic"}]} + results = run_scenario(scenario, "kernel panic", 5.0) + assert not results[0].passed + + def test_count_matches(self): + from eosim.tests.scenarios import run_scenario + scenario = {"steps": [{"type": "count_matches", "pattern": "OK", "min_count": 3}]} + results = run_scenario(scenario, "OK OK OK OK", 5.0) + assert results[0].passed + + +class TestCluster: + def test_from_yaml(self, tmp_path): + from eosim.core.cluster import Cluster + cfg = tmp_path / "cluster.yml" + cfg.write_text("name: test\nnodes:\n - name: n1\n platform: arm64-linux\nlinks: []") + c = Cluster.from_yaml(str(cfg)) + assert c.name == "test" + assert len(c.nodes) == 1 + + def test_validate_ok(self): + from eosim.core.cluster import Cluster, ClusterNode + c = Cluster(name="test", nodes=[ClusterNode(name="n1", platform="arm64-linux")]) + errors = c.validate({"arm64-linux": None}) + assert len(errors) == 0 + + def test_validate_duplicate_node(self): + from eosim.core.cluster import Cluster, ClusterNode + c = Cluster(name="test", nodes=[ + ClusterNode(name="n1", platform="arm64-linux"), + ClusterNode(name="n1", platform="arm64-linux")]) + errors = c.validate({"arm64-linux": None}) + assert any("duplicate" in e for e in errors) + + +class TestPeripherals: + def test_list(self): + from eosim.engine.peripherals import list_peripherals + perips = list_peripherals() + assert "uart" in perips + assert "spi" in perips + assert "gpio" in perips + assert len(perips) >= 8 + + def test_generate_repl(self): + from eosim.engine.peripherals import generate_repl_peripherals + repl = generate_repl_peripherals(["uart", "spi"]) + assert "UART" in repl + assert "SPI" in repl + + +class TestJobQueue: + def test_submit_and_get(self, tmp_path): + from eosim.core.jobs import JobQueue + q = JobQueue(str(tmp_path)) + job = q.submit("arm64-linux", "qemu") + assert job.status == "pending" + got = q.get(job.job_id) + assert got is not None + assert got.platform == "arm64-linux" + + def test_list_jobs(self, tmp_path): + import time + + from eosim.core.jobs import JobQueue + q = JobQueue(str(tmp_path)) + q.submit("arm64-linux") + time.sleep(0.01) + q.submit("riscv64-linux") + jobs = q.list_jobs() + assert len(jobs) >= 1 + + def test_update_status(self, tmp_path): + from eosim.core.jobs import JobQueue + q = JobQueue(str(tmp_path)) + job = q.submit("arm64-linux") + q.update(job.job_id, status="completed") + updated = q.get(job.job_id) + assert updated.status == "completed" + +class TestNativeEngine: + def test_vm_create(self): + from eosim.engine.native import VirtualMachine + vm = VirtualMachine('test', 'arm64', ram_mb=64) + assert vm.name == 'test' + assert vm.arch == 'arm64' + assert len(vm.peripherals) >= 5 + + def test_vm_run(self): + """Runs a real image. This previously called run() with NO firmware + and asserted success, which is what allowed the engine to report a + successful EoS boot for stepping over zeroed memory.""" + from eosim.engine.native import VirtualMachine + vm = VirtualMachine('test', 'arm', ram_mb=32) + vm.load_binary(_tiny_arm_image(), addr=0x08000000) + result = vm.run(max_cycles=100, timeout_s=5) + assert result['success'] + assert result['reason'] == 'halted' + assert result['cycles'] > 0 + assert 'EoSim' in result['boot_log'] + + def test_vm_run_without_firmware_is_not_a_boot(self): + from eosim.engine.native import VirtualMachine + vm = VirtualMachine('test', 'arm', ram_mb=32) + result = vm.run(max_cycles=100, timeout_s=5) + assert result['success'] is False + assert result['reason'] == 'no-firmware' + + def test_memory(self): + from eosim.engine.native.memory import MemoryBus, MemoryRegion + bus = MemoryBus() + bus.add_region(MemoryRegion('ram', 0x20000000, 1024)) + bus.write32(0x20000000, 0xDEADBEEF) + assert bus.read32(0x20000000) == 0xDEADBEEF + bus.write8(0x20000010, 0x42) + assert bus.read8(0x20000010) == 0x42 + + def test_uart(self): + from eosim.engine.native.peripherals import UARTDevice + uart = UARTDevice('uart0', 0x40000000) + uart.write_reg(0x00, ord('H')) + uart.write_reg(0x00, ord('i')) + assert uart.get_output() == 'Hi' + + def test_gpio(self): + from eosim.engine.native.peripherals import GPIODevice + gpio = GPIODevice('gpio0', 0x40010000) + gpio.write_reg(0x00, 0xFF) # direction = output + gpio.write_reg(0x04, 0x55) # output value + assert gpio.read_reg(0x04) == 0x55 + gpio.set_input(0, True) + assert gpio.read_reg(0x08) & 1 == 1 + + def test_timer(self): + from eosim.engine.native.peripherals import TimerDevice + timer = TimerDevice('timer0', 0x40020000) + timer.write_reg(0x00, 10) # reload = 10 + timer.write_reg(0x04, 3) # enable + irq + for _ in range(10): + timer.tick() + assert timer.irq_pending + + def test_spi(self): + from eosim.engine.native.peripherals import SPIDevice + spi = SPIDevice('spi0', 0x40030000) + spi.write_reg(0x04, 1) # enable + spi.write_reg(0x00, 0xAA) # tx + assert spi.read_reg(0x04) & 2 # transfer complete + + def test_i2c(self): + from eosim.engine.native.peripherals import I2CDevice + i2c = I2CDevice('i2c0', 0x40040000) + i2c.add_slave(0x50, lambda v: 0x42) + i2c.write_reg(0x00, 0x50) # slave addr + i2c.write_reg(0x04, 0x01) # tx + assert i2c.read_reg(0x04) == 0x42 # rx from slave + assert i2c.read_reg(0x08) == 1 # ACK + + def test_interrupt_controller(self): + from eosim.engine.native.peripherals import InterruptController + nvic = InterruptController('nvic', 0xE000E000) + nvic.enable_irq(5) + nvic.trigger(5) + assert nvic.get_highest_pending() == 5 + nvic.acknowledge(5) + assert nvic.get_highest_pending() == -1 + + def test_cpu_state(self): + from eosim.engine.native.cpu import CPUSimulator + cpu = CPUSimulator('arm64') + cpu.reset(entry=0x08000000, stack=0x20001000) + assert cpu.state.pc == 0x08000000 + assert cpu.state.sp == 0x20001000 + + def test_eosim_engine(self): + from eosim.engine.backend import EoSimEngine + assert EoSimEngine.available() + + +class TestPlatformExtendedFields: + """Tests for new Platform metadata fields.""" + + def test_from_yaml_with_metadata(self, tmp_path): + cfg = tmp_path / "platform.yml" + cfg.write_text(yaml.dump({ + "name": "test-mcu", "arch": "arm", "engine": "eosim", + "vendor": "ST", "class": "mcu", "soc": "STM32F407", + "domain": "industrial", "modeling": "deterministic", + "domain_config": {"safety_level": "SIL-2"}, + "modeling_config": {"seed": 42}, + "runtime": {"memory_mb": 128}, + })) + p = Platform.from_yaml(str(cfg)) + assert p.vendor == "ST" + assert p.platform_class == "mcu" + assert p.soc == "STM32F407" + assert p.domain == "industrial" + assert p.modeling == "deterministic" + assert p.domain_config == {"safety_level": "SIL-2"} + assert p.modeling_config == {"seed": 42} + + def test_from_yaml_without_metadata(self, tmp_path): + cfg = tmp_path / "platform.yml" + cfg.write_text(yaml.dump({"name": "bare", "arch": "arm64", "engine": "qemu"})) + p = Platform.from_yaml(str(cfg)) + assert p.vendor == "" + assert p.platform_class == "" + assert p.soc == "" + assert p.domain == "" + assert p.modeling == "" + assert p.domain_config == {} + assert p.modeling_config == {} + + def test_backward_compat_defaults(self): + p = Platform() + assert p.vendor == "" + assert p.platform_class == "" + assert p.soc == "" + assert p.domain == "" + assert p.modeling == "" + + def test_class_keyword_mapping(self, tmp_path): + cfg = tmp_path / "platform.yml" + cfg.write_text(yaml.dump({ + "name": "t", "arch": "arm", "engine": "eosim", "class": "sbc", + })) + p = Platform.from_yaml(str(cfg)) + assert p.platform_class == "sbc" + + +class TestRegistry: + """Tests for PlatformRegistry filter, group, search, stats.""" + + def _make_registry(self): + from eosim.core.registry import PlatformRegistry + p1 = Platform(name="stm32f4", arch="arm", engine="eosim", + vendor="ST", platform_class="mcu", soc="STM32F407", + domain="industrial") + p2 = Platform(name="nrf52", arch="arm", engine="eosim", + vendor="Nordic", platform_class="mcu", soc="nRF52840", + domain="iot") + p3 = Platform(name="raspi4", arch="arm64", engine="renode", + vendor="Raspberry Pi", platform_class="sbc", soc="BCM2711", + domain="consumer") + p4 = Platform(name="esp32", arch="xtensa", engine="eosim", + vendor="Espressif", platform_class="mcu", soc="ESP32", + domain="iot") + return PlatformRegistry.from_dict({ + "stm32f4": p1, "nrf52": p2, "raspi4": p3, "esp32": p4, + }) + + def test_count(self): + reg = self._make_registry() + assert reg.count() == 4 + + def test_all(self): + reg = self._make_registry() + assert len(reg.all()) == 4 + + def test_get_existing(self): + reg = self._make_registry() + p = reg.get("stm32f4") + assert p is not None + assert p.vendor == "ST" + + def test_get_missing(self): + reg = self._make_registry() + assert reg.get("nonexistent") is None + + def test_filter_by_arch(self): + reg = self._make_registry() + results = reg.filter(arch="arm") + assert len(results) == 2 + assert all(p.arch == "arm" for p in results) + + def test_filter_by_vendor(self): + reg = self._make_registry() + results = reg.filter(vendor="ST") + assert len(results) == 1 + assert results[0].name == "stm32f4" + + def test_filter_by_class(self): + reg = self._make_registry() + results = reg.filter(platform_class="mcu") + assert len(results) == 3 + + def test_filter_by_engine(self): + reg = self._make_registry() + results = reg.filter(engine="eosim") + assert len(results) == 3 + + def test_filter_by_domain(self): + reg = self._make_registry() + results = reg.filter(domain="iot") + assert len(results) == 2 + + def test_filter_combined(self): + reg = self._make_registry() + results = reg.filter(arch="arm", vendor="Nordic") + assert len(results) == 1 + assert results[0].name == "nrf52" + + def test_filter_no_results(self): + reg = self._make_registry() + results = reg.filter(arch="mipsel", vendor="ST") + assert len(results) == 0 + + def test_filter_case_insensitive(self): + reg = self._make_registry() + results = reg.filter(vendor="st") + assert len(results) == 1 + + def test_group_by_arch(self): + reg = self._make_registry() + groups = reg.group_by("arch") + assert "arm" in groups + assert "arm64" in groups + assert len(groups["arm"]) == 2 + + def test_group_by_vendor(self): + reg = self._make_registry() + groups = reg.group_by("vendor") + assert "ST" in groups + assert "Nordic" in groups + + def test_search(self): + reg = self._make_registry() + results = reg.search("STM32") + assert len(results) == 1 + assert results[0].name == "stm32f4" + + def test_search_case_insensitive(self): + reg = self._make_registry() + results = reg.search("espressif") + assert len(results) == 1 + assert results[0].name == "esp32" + + def test_search_by_arch(self): + reg = self._make_registry() + results = reg.search("xtensa") + assert len(results) >= 1 + + def test_stats(self): + reg = self._make_registry() + st = reg.stats() + assert "arch" in st + assert "vendor" in st + assert "platform_class" in st + assert "engine" in st + assert st["arch"]["arm"] == 2 + + def test_vendors(self): + reg = self._make_registry() + vendors = reg.vendors() + assert "ST" in vendors + assert "Nordic" in vendors + + def test_arches(self): + reg = self._make_registry() + arches = reg.arches() + assert "arm" in arches + assert "arm64" in arches + + def test_classes(self): + reg = self._make_registry() + classes = reg.classes() + assert "mcu" in classes + assert "sbc" in classes + + def test_registry_from_dir(self): + from eosim.core.registry import PlatformRegistry + root = os.path.join(os.path.dirname(__file__), "..", "..", "eosim", "platforms") + reg = PlatformRegistry(root) + assert reg.count() >= 41 + + +class TestHostEnvironment: + """Tests for HostEnvironment detection and binary resolution.""" + + def test_detect(self): + from eosim.core.host import HostEnvironment + env = HostEnvironment.detect() + assert env.os_name in ("windows", "macos", "linux") + assert env.arch != "" + assert env.python_version != "" + + def test_platform_info(self): + from eosim.core.host import HostEnvironment + env = HostEnvironment.detect() + info = env.platform_info() + assert "os" in info + assert "python" in info + assert "arch" in info + assert "shell" in info + + def test_adapt_path_windows(self): + from eosim.core.host import HostEnvironment + env = HostEnvironment(os_name="windows") + assert "\\" in env.adapt_path("some/path/to/file") + + def test_adapt_path_linux(self): + from eosim.core.host import HostEnvironment + env = HostEnvironment(os_name="linux") + assert "/" in env.adapt_path("some\\path\\to\\file") + + def test_resolve_binary_none(self): + from eosim.core.host import HostEnvironment + env = HostEnvironment.detect() + result = env.resolve_binary("this_binary_definitely_does_not_exist_12345") + assert result is None + + +class TestDomains: + """Tests for domain profiles and catalog.""" + + def test_catalog_complete(self): + from eosim.core.domains import list_domains + domains = list_domains() + assert len(domains) >= 40 + expected = ["automotive", "medical", "industrial", "consumer", + "aerospace", "iot", "robotics", "defense", "energy", "telecom", + "aerodynamics", "physiology", "finance", "weather", "gaming", + "agriculture", "maritime", "nuclear", "railway", "quantum"] + for d in expected: + assert d in domains + + def test_get_domain(self): + from eosim.core.domains import get_domain + d = get_domain("automotive") + assert d is not None + assert d.display_name == "Automotive / Transportation" + assert "ISO 26262" in d.standards + assert len(d.safety_levels) == 4 + + def test_get_domain_missing(self): + from eosim.core.domains import get_domain + assert get_domain("nonexistent") is None + + def test_suggest_platforms(self): + from eosim.core.domains import suggest_platforms + from eosim.core.registry import PlatformRegistry + p1 = Platform(name="auto-mcu", arch="arm", platform_class="mcu", + domain="automotive") + p2 = Platform(name="iot-sensor", arch="xtensa", platform_class="mcu", + domain="iot") + reg = PlatformRegistry.from_dict({"auto-mcu": p1, "iot-sensor": p2}) + results = suggest_platforms("automotive", reg) + assert any(p.name == "auto-mcu" for p in results) + + +class TestModeling: + """Tests for modeling method catalog.""" + + def test_catalog_complete(self): + from eosim.core.modeling import list_modeling_methods + methods = list_modeling_methods() + assert len(methods) >= 20 + expected = ["deterministic", "stochastic", "discrete-event", + "continuous", "hybrid", "agent-based", "cfd", + "monte-carlo", "finite-element", "particle-based"] + for m in expected: + assert m in methods + + def test_get_modeling(self): + from eosim.core.modeling import get_modeling + m = get_modeling("deterministic") + assert m is not None + assert "eosim" in m.engine_support + assert "renode" in m.engine_support + + def test_get_modeling_missing(self): + from eosim.core.modeling import get_modeling + assert get_modeling("nonexistent") is None + + def test_validate_compatible(self): + from eosim.core.modeling import validate_modeling_for_engine + warnings = validate_modeling_for_engine("deterministic", "eosim") + assert len(warnings) == 0 + + def test_validate_incompatible(self): + from eosim.core.modeling import validate_modeling_for_engine + warnings = validate_modeling_for_engine("stochastic", "qemu") + assert len(warnings) > 0 + assert "not supported" in warnings[0] + + def test_validate_unknown_method(self): + from eosim.core.modeling import validate_modeling_for_engine + warnings = validate_modeling_for_engine("unknown_method", "eosim") + assert len(warnings) > 0 + assert "Unknown" in warnings[0] + + +class TestSchemaExtended: + """Tests for extended schema validation (class, domain, modeling).""" + + def test_valid_eosim_engine(self): + from eosim.core.schema import validate_platform + data = {"name": "t", "arch": "arm", "engine": "eosim"} + assert validate_platform(data) == [] + + def test_valid_class(self): + from eosim.core.schema import validate_platform + data = {"name": "t", "arch": "arm", "engine": "eosim", "class": "mcu"} + assert validate_platform(data) == [] + + def test_invalid_class(self): + from eosim.core.schema import validate_platform + errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", + "class": "invalid_class"}) + assert any("invalid class" in e for e in errors) + + def test_valid_domain(self): + from eosim.core.schema import validate_platform + data = {"name": "t", "arch": "arm", "engine": "eosim", + "domain": "automotive"} + assert validate_platform(data) == [] + + def test_invalid_domain(self): + from eosim.core.schema import validate_platform + errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", + "domain": "invalid_domain"}) + assert any("invalid domain" in e for e in errors) + + def test_valid_modeling(self): + from eosim.core.schema import validate_platform + data = {"name": "t", "arch": "arm", "engine": "eosim", + "modeling": "deterministic"} + assert validate_platform(data) == [] + + def test_invalid_modeling(self): + from eosim.core.schema import validate_platform + errors = validate_platform({"name": "t", "arch": "arm", "engine": "eosim", + "modeling": "invalid_method"}) + assert any("invalid modeling" in e for e in errors) diff --git a/tests/unit/test_engines_and_integrations.py b/tests/unit/test_engines_and_integrations.py index e313417..3a5df14 100644 --- a/tests/unit/test_engines_and_integrations.py +++ b/tests/unit/test_engines_and_integrations.py @@ -1,231 +1,254 @@ -# SPDX-License-Identifier: MIT -"""Unit tests for QEMU bridge (QMP, GDB, state bridge, ELF loader) and engine run methods.""" -from unittest.mock import MagicMock, patch - - -class TestQMPClient: - def test_import(self): - from eosim.engine.qemu.qmp_client import QMPClient - client = QMPClient() - assert client is not None - - def test_not_connected(self): - from eosim.engine.qemu.qmp_client import QMPClient - client = QMPClient() - assert not hasattr(client, '_connected') or not client._connected - - -class TestGDBClient: - def test_import(self): - from eosim.engine.qemu.gdb_client import GDBRemoteClient - client = GDBRemoteClient() - assert client is not None - - -class TestStateBridge: - def test_import(self): - from eosim.engine.qemu.state_bridge import TargetStateBridge - # Should be importable without errors - assert TargetStateBridge is not None - - -class TestELFLoader: - def test_import(self): - try: - from eosim.engine.qemu.elf_loader import ELFLoader - assert ELFLoader is not None - except ImportError: - pass # pyelftools not installed — skip - - -class TestQemuLiveEngine: - def test_available(self): - from eosim.engine.backend import QemuLiveEngine - # Just test it doesn't crash - result = QemuLiveEngine.available() - assert isinstance(result, bool) - - def test_init(self): - from eosim.engine.backend import QemuLiveEngine - engine = QemuLiveEngine() - assert engine._process is None - assert engine._qmp is None - assert engine._gdb is None - - def test_properties(self): - from eosim.engine.backend import QemuLiveEngine - engine = QemuLiveEngine() - assert engine.qmp is None - assert engine.gdb is None - assert engine.state_bridge is None - - def test_stop_no_process(self): - from eosim.engine.backend import QemuLiveEngine - engine = QemuLiveEngine() - engine.stop() # should not crash - - -class TestRenodeEngineRun: - def test_run_no_renode(self): - from eosim.engine.backend import RenodeEngine, SimResult - platform = MagicMock() - platform.name = 'test' - platform.resc = '' - platform.source_dir = '/tmp' - with patch('shutil.which', return_value=None): - result = RenodeEngine.run(platform) - assert isinstance(result, SimResult) - assert result.engine == 'renode' - assert 'not installed' in result.stderr - - -class TestQemuEngineRun: - def test_run_no_qemu(self): - from eosim.engine.backend import QemuEngine, SimResult - platform = MagicMock() - platform.name = 'test' - platform.arch = 'arm64' - with patch('shutil.which', return_value=None): - result = QemuEngine.run(platform) - assert isinstance(result, SimResult) - assert result.engine == 'qemu' - assert result.success is True # dry run - - def test_run_dry_run_with_log(self, tmp_path): - from eosim.engine.backend import QemuEngine, SimResult - platform = MagicMock() - platform.name = 'test' - platform.arch = 'arm64' - log_file = str(tmp_path / 'test.log') - with patch('shutil.which', return_value=None): - result = QemuEngine.run(platform, log_file=log_file) - assert result.success is True - assert result.log_file == log_file - - -class TestEoSimEngineRun: - def test_run(self): - from eosim.engine.backend import EoSimEngine, SimResult - platform = MagicMock() - platform.name = 'test' - platform.arch = 'arm' - platform.runtime.memory_mb = 64 - platform.boot.firmware = '' - platform.source_dir = '/tmp' - result = EoSimEngine.run(platform, timeout=5) - assert isinstance(result, SimResult) - assert result.engine == 'eosim' - assert result.success is True - - -class TestCARLAEngineRun: - def test_run_not_available(self): - from eosim.engine.backend import CARLAEngine, SimResult - platform = MagicMock() - platform.name = 'test' - result = CARLAEngine.run(platform) - assert isinstance(result, SimResult) - assert result.success is False - - -class TestAirSimEngineRun: - def test_run_not_available(self): - from eosim.engine.backend import AirSimEngine, SimResult - platform = MagicMock() - platform.name = 'test' - result = AirSimEngine.run(platform) - assert isinstance(result, SimResult) - assert result.success is False - - -class TestROS2EngineRun: - def test_run_not_available(self): - from eosim.engine.backend import ROS2Engine, SimResult - platform = MagicMock() - platform.name = 'test' - result = ROS2Engine.run(platform) - assert isinstance(result, SimResult) - assert result.success is False - - -class TestXPlaneEngineRun: - def test_run_not_available(self): - from eosim.engine.backend import XPlaneEngine, SimResult - platform = MagicMock() - platform.name = 'test' - result = XPlaneEngine.run(platform) - assert isinstance(result, SimResult) - # X-Plane may or may not be available - assert isinstance(result.success, bool) - - -class TestGazeboEngineRun: - def test_available_no_gazebo(self): - from eosim.engine.backend import GazeboEngine - with patch('shutil.which', return_value=None): - assert GazeboEngine.available() is False - - -class TestOpenFOAMEngine: - def test_available_no_openfoam(self): - from eosim.engine.backend import OpenFOAMEngine - with patch('shutil.which', return_value=None): - assert OpenFOAMEngine.available() is False - - -# ─── Integration module tests ──────────────────────────────────────── - -class TestSerialBridge: - def test_import(self): - from eosim.integrations.serial_bridge import SerialBridge - assert SerialBridge is not None - - def test_available(self): - from eosim.integrations.serial_bridge import SerialBridge - result = SerialBridge.available() - assert isinstance(result, bool) - - -class TestOpenOCDManager: - def test_import(self): - from eosim.integrations.openocd import OpenOCDManager - mgr = OpenOCDManager() - assert mgr is not None - - def test_find_openocd(self): - from eosim.integrations.openocd import OpenOCDManager - result = OpenOCDManager.find_openocd() - # May or may not be installed - assert result is None or isinstance(result, str) - - -class TestHILSession: - def test_import(self): - from eosim.integrations.hil_session import HILSession - session = HILSession() - assert session is not None - - -class TestEcosystem: - def test_import(self): - from eosim.integrations.ecosystem import find_repos - # Should be importable - assert find_repos is not None - - def test_find_repos_none(self): - from eosim.integrations.ecosystem import find_repos - repos = find_repos('/nonexistent/path') - assert isinstance(repos, dict) - - -class TestEosRunner: - def test_import(self): - from eosim.integrations.eos_runner import find_eos_source - assert find_eos_source is not None - - def test_find_eos_not_found(self): - from eosim.integrations.eos_runner import find_eos_source - with patch.dict('os.environ', {'EOS_SOURCE': '/nonexistent'}, clear=False): - # May or may not find it depending on local setup - result = find_eos_source() - assert result is None or isinstance(result, str) +# SPDX-License-Identifier: MIT +"""Unit tests for QEMU bridge (QMP, GDB, state bridge, ELF loader) and engine run methods.""" +from unittest.mock import MagicMock, patch + + +class TestQMPClient: + def test_import(self): + from eosim.engine.qemu.qmp_client import QMPClient + client = QMPClient() + assert client is not None + + def test_not_connected(self): + from eosim.engine.qemu.qmp_client import QMPClient + client = QMPClient() + assert not hasattr(client, '_connected') or not client._connected + + +class TestGDBClient: + def test_import(self): + from eosim.engine.qemu.gdb_client import GDBRemoteClient + client = GDBRemoteClient() + assert client is not None + + +class TestStateBridge: + def test_import(self): + from eosim.engine.qemu.state_bridge import TargetStateBridge + # Should be importable without errors + assert TargetStateBridge is not None + + +class TestELFLoader: + def test_import(self): + try: + from eosim.engine.qemu.elf_loader import ELFLoader + assert ELFLoader is not None + except ImportError: + pass # pyelftools not installed — skip + + +class TestQemuLiveEngine: + def test_available(self): + from eosim.engine.backend import QemuLiveEngine + # Just test it doesn't crash + result = QemuLiveEngine.available() + assert isinstance(result, bool) + + def test_init(self): + from eosim.engine.backend import QemuLiveEngine + engine = QemuLiveEngine() + assert engine._process is None + assert engine._qmp is None + assert engine._gdb is None + + def test_properties(self): + from eosim.engine.backend import QemuLiveEngine + engine = QemuLiveEngine() + assert engine.qmp is None + assert engine.gdb is None + assert engine.state_bridge is None + + def test_stop_no_process(self): + from eosim.engine.backend import QemuLiveEngine + engine = QemuLiveEngine() + engine.stop() # should not crash + + +class TestRenodeEngineRun: + def test_run_no_renode(self): + from eosim.engine.backend import RenodeEngine, SimResult + platform = MagicMock() + platform.name = 'test' + platform.resc = '' + platform.source_dir = '/tmp' + with patch('shutil.which', return_value=None): + result = RenodeEngine.run(platform) + assert isinstance(result, SimResult) + assert result.engine == 'renode' + assert 'not installed' in result.stderr + + +class TestQemuEngineRun: + def test_run_no_qemu(self): + from eosim.engine.backend import QemuEngine, SimResult + platform = MagicMock() + platform.name = 'test' + platform.arch = 'arm64' + with patch('shutil.which', return_value=None): + result = QemuEngine.run(platform) + assert isinstance(result, SimResult) + assert result.engine == 'qemu' + assert result.success is True # dry run + + def test_run_dry_run_with_log(self, tmp_path): + from eosim.engine.backend import QemuEngine, SimResult + platform = MagicMock() + platform.name = 'test' + platform.arch = 'arm64' + log_file = str(tmp_path / 'test.log') + with patch('shutil.which', return_value=None): + result = QemuEngine.run(platform, log_file=log_file) + assert result.success is True + assert result.log_file == log_file + + +class TestEoSimEngineRun: + def test_run_without_firmware_is_not_success(self): + """platform.boot.firmware is empty, so nothing is executed. + + This asserted success is True, which is how the engine came to report a + successful run for a platform with no image. + """ + from eosim.engine.backend import EoSimEngine, SimResult + platform = MagicMock() + platform.name = 'test' + platform.arch = 'arm' + platform.runtime.memory_mb = 64 + platform.boot.firmware = '' + platform.source_dir = '/tmp' + result = EoSimEngine.run(platform, timeout=5) + assert isinstance(result, SimResult) + assert result.engine == 'eosim' + assert result.success is False + + def test_run_with_firmware_succeeds(self, tmp_path): + """The same path with a real image, proving boot.firmware is wired.""" + import struct + + from eosim.engine.backend import EoSimEngine, SimResult + img = tmp_path / 'fw.bin' + img.write_bytes(b''.join(struct.pack('= 79 - - def test_all_template_names(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - # Check core templates still exist (subset check, not exact match) - core_templates = { - "iot_sensor", "smart_home_hub", "automotive_ecu", - "ev_powertrain", "adas_controller", - "medical_monitor", "drone_controller", "industrial_plc", - "wearable_device", "robot_controller", - "fixed_wing", "cubesat", "solar_inverter", - } - assert core_templates.issubset(set(PRODUCT_CATALOG.keys())) - - def test_templates_have_required_fields(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - required = [ - "name", "display_name", "icon", "arch", "ram_mb", - "peripherals", "domain", "modeling", "description", - "default_platform", - ] - for key, tpl in PRODUCT_CATALOG.items(): - for field in required: - assert hasattr(tpl, field), f"Template '{key}' missing '{field}'" - val = getattr(tpl, field) - if field == "ram_mb": - assert val > 0, f"{key}.ram_mb must be > 0" - elif field == "peripherals": - assert isinstance(val, list) - assert len(val) > 0, f"{key} must have peripherals" - else: - assert val, f"Template '{key}' field '{field}' is empty" - - def test_simulator_class_field(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - for key, tpl in PRODUCT_CATALOG.items(): - assert hasattr(tpl, 'simulator_class'), f"{key} missing simulator_class" - assert tpl.simulator_class, f"{key} simulator_class is empty" - - def test_valid_arch(self): - from eosim.core.schema import VALID_ARCHES - from eosim.gui.product_templates import PRODUCT_CATALOG - for key, tpl in PRODUCT_CATALOG.items(): - assert tpl.arch in VALID_ARCHES, f"{key} invalid arch: {tpl.arch}" - - def test_valid_domain(self): - from eosim.core.schema import VALID_DOMAINS - from eosim.gui.product_templates import PRODUCT_CATALOG - for key, tpl in PRODUCT_CATALOG.items(): - assert tpl.domain in VALID_DOMAINS, f"{key} invalid domain: {tpl.domain}" - - def test_valid_modeling(self): - from eosim.core.schema import VALID_MODELING - from eosim.gui.product_templates import PRODUCT_CATALOG - for key, tpl in PRODUCT_CATALOG.items(): - assert tpl.modeling in VALID_MODELING, f"{key} invalid modeling" - - def test_valid_peripherals(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - from eosim.gui.widgets.build_panel import ALL_PERIPHERALS - for key, tpl in PRODUCT_CATALOG.items(): - for p in tpl.peripherals: - assert p in ALL_PERIPHERALS, f"{key} invalid peripheral: {p}" - - def test_get_template(self): - from eosim.gui.product_templates import get_template - tpl = get_template("iot_sensor") - assert tpl is not None - assert tpl.name == "iot_sensor" - assert get_template("nonexistent") is None - - def test_list_templates(self): - from eosim.gui.product_templates import list_templates - names = list_templates() - assert len(names) >= 79 - assert names == sorted(names) - - -class TestBuildPanel: - """Verify build config generation from product templates.""" - - def test_iot_sensor_defaults(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - tpl = PRODUCT_CATALOG["iot_sensor"] - assert tpl.arch == "arm" - assert tpl.ram_mb == 64 - assert "uart" in tpl.peripherals - assert "gpio" in tpl.peripherals - assert "i2c" in tpl.peripherals - assert tpl.domain == "iot" - - def test_all_templates_have_valid_config(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - for key, tpl in PRODUCT_CATALOG.items(): - assert isinstance(tpl.arch, str) and tpl.arch - assert isinstance(tpl.ram_mb, int) and tpl.ram_mb > 0 - assert isinstance(tpl.peripherals, list) and len(tpl.peripherals) > 0 - - def test_robot_controller_is_arm64(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - tpl = PRODUCT_CATALOG["robot_controller"] - assert tpl.arch == "arm64" - assert tpl.ram_mb == 512 - - def test_wearable_is_smallest_ram(self): - from eosim.gui.product_templates import PRODUCT_CATALOG - tpl = PRODUCT_CATALOG["wearable_device"] - assert tpl.ram_mb == 32 - all_rams = [t.ram_mb for t in PRODUCT_CATALOG.values()] - assert tpl.ram_mb == min(all_rams) - - def test_build_panel_select_product(self): - from eosim.gui.widgets.build_panel import BuildPanel - bp = BuildPanel() - assert bp.select_product("automotive_ecu") - config = bp.get_build_config() - assert config['product'] == 'automotive_ecu' - assert 'can' in config['peripherals'] - assert config['arch'] == 'arm' - - def test_build_panel_toggle_peripheral(self): - from eosim.gui.widgets.build_panel import BuildPanel - bp = BuildPanel() - bp.select_product("iot_sensor") - assert bp.toggle_peripheral("wifi") is True - config = bp.get_build_config() - assert 'wifi' in config['peripherals'] - assert bp.toggle_peripheral("wifi") is False - config = bp.get_build_config() - assert 'wifi' not in config['peripherals'] - - def test_build_panel_peripheral_groups(self): - from eosim.gui.widgets.build_panel import BuildPanel - bp = BuildPanel() - bp.select_product("drone_controller") - groups = bp.get_peripheral_groups() - assert 'Core' in groups - assert 'Sensors' in groups - assert 'Actuators' in groups - assert 'Buses' in groups - assert 'Wireless' in groups - assert 'Composite' in groups - - def test_build_panel_list_products(self): - from eosim.gui.widgets.build_panel import BuildPanel - bp = BuildPanel() - products = bp.list_products() - assert len(products) >= 79 - names = [p['name'] for p in products] - assert names == sorted(names) - - -class TestSensors: - """Verify sensor simulate_tick(), register reads, and value injection.""" - - def test_temperature_sensor_tick(self): - from eosim.engine.native.peripherals.sensors import TemperatureSensor - s = TemperatureSensor('t', 0x1000) - for _ in range(100): - s.simulate_tick() - assert s._tick_count == 100 - assert s.read_reg(0x00) == int(s.temperature * 100) & 0xFFFFFFFF - - def test_temperature_sensor_set_value(self): - from eosim.engine.native.peripherals.sensors import TemperatureSensor - s = TemperatureSensor('t', 0x1000) - s.set_value(42.5, 80.0) - assert s.temperature == 42.5 - assert s.humidity == 80.0 - - def test_imu_sensor_axes(self): - from eosim.engine.native.peripherals.sensors import IMUSensor - imu = IMUSensor('imu', 0x2000, 9) - imu.set_accel(1.0, 2.0, 9.81) - assert imu.accel == [1.0, 2.0, 9.81] - assert imu.read_reg(0x00) == 1000 - assert imu.read_reg(0x04) == 2000 - - def test_gps_module_position(self): - from eosim.engine.native.peripherals.sensors import GPSModule - gps = GPSModule('gps', 0x3000) - gps.set_position(40.7128, -74.0060, 10) - assert gps.latitude == 40.7128 - assert gps.longitude == -74.0060 - - def test_proximity_sensor(self): - from eosim.engine.native.peripherals.sensors import ProximitySensor - p = ProximitySensor('prox', 0x4000, max_range_cm=400) - p.set_value(50.0) - assert p.distance_cm == 50.0 - assert p.detected is True - - def test_ecg_sensor_waveform(self): - from eosim.engine.native.peripherals.sensors import ECGSensor - ecg = ECGSensor('ecg', 0x5000) - ecg.set_heart_rate(100) - assert ecg.heart_rate_bpm == 100 - for _ in range(50): - ecg.simulate_tick() - assert len(ecg.waveform) == 256 - assert ecg.read_reg(0x00) == 100 - - def test_pulse_oximeter(self): - from eosim.engine.native.peripherals.sensors import PulseOximeter - spo2 = PulseOximeter('spo2', 0x6000) - spo2.set_value(95.0, 80) - assert spo2.spo2_percent == 95.0 - assert spo2.pulse_rate == 80 - - def test_adc_channel(self): - from eosim.engine.native.peripherals.sensors import ADCChannel - adc = ADCChannel('adc', 0x7000, channels=4, resolution=12) - adc.set_channel(0, 2048) - assert adc.values[0] == 2048 - assert adc.read_reg(0x00) == 2048 - - def test_pressure_sensor_altitude(self): - from eosim.engine.native.peripherals.sensors import PressureSensor - baro = PressureSensor('baro', 0x8000) - baro.set_altitude(1000) - assert abs(baro.altitude_m - 1000) < 1 - - def test_sensor_io_handler(self): - from eosim.engine.native.peripherals.sensors import TemperatureSensor - s = TemperatureSensor('t', 0x1000) - s.set_value(25.0) - val = s.io_handler('read', 0x1000, 0) - assert val == int(25.0 * 100) - - -class TestActuators: - """Verify motor/servo/ESC command response and state.""" - - def test_motor_controller_enable(self): - from eosim.engine.native.peripherals.actuators import MotorController - m = MotorController('m', 0x1000) - m.enabled = True - m.target_speed = 1000 - for _ in range(50): - m.simulate_tick() - assert m.speed_rpm > 0 - - def test_servo_controller_target(self): - from eosim.engine.native.peripherals.actuators import ServoController - s = ServoController('s', 0x2000, channels=4) - s.set_target(0, 45.0) - assert s.targets[0] == 45.0 - for _ in range(100): - s.simulate_tick() - assert abs(s.positions[0] - 45.0) < 5.0 - - def test_esc_controller_armed(self): - from eosim.engine.native.peripherals.actuators import ESCController - esc = ESCController('esc', 0x3000, channels=4) - esc.armed = True - esc.enabled = True - esc.throttle = [50.0, 50.0, 50.0, 50.0] - for _ in range(50): - esc.simulate_tick() - for rpm in esc.rpm: - assert rpm > 0 - - def test_brake_actuator(self): - from eosim.engine.native.peripherals.actuators import BrakeActuator - b = BrakeActuator('b', 0x4000) - b.target_pct = 80.0 - for _ in range(20): - b.simulate_tick() - assert b.pressure_pct > 50.0 - - def test_steering_actuator(self): - from eosim.engine.native.peripherals.actuators import SteeringActuator - s = SteeringActuator('s', 0x5000) - s.target_angle = 30.0 - for _ in range(20): - s.simulate_tick() - assert abs(s.angle_deg - 30.0) < 2.0 - - def test_relay_bank_toggle(self): - from eosim.engine.native.peripherals.actuators import RelayBank - r = RelayBank('r', 0x6000, channels=4) - r.write_reg(0x00, 0b0101) - assert r.states[0] is True - assert r.states[1] is False - assert r.states[2] is True - - def test_pump_controller(self): - from eosim.engine.native.peripherals.actuators import PumpController - p = PumpController('p', 0x7000) - p.enabled = True - p.target_flow = 100.0 - for _ in range(50): - p.simulate_tick() - assert p.flow_rate_ml_min > 50.0 - - def test_display_driver(self): - from eosim.engine.native.peripherals.actuators import DisplayDriver - d = DisplayDriver('d', 0x8000, 128, 64) - assert d.width == 128 - assert d.height == 64 - assert len(d.framebuffer) == 128 * 64 // 8 - - -class TestBuses: - """Verify CAN message send/receive, Modbus register read/write.""" - - def test_can_send_receive(self): - from eosim.engine.native.peripherals.buses import CANBusController - can = CANBusController('can', 0x1000) - can.loopback = True - can.send_message(0x100, b'\x01\x02\x03') - assert can.tx_count == 1 - msg = can.receive_message() - assert msg is not None - assert msg['id'] == 0x100 - assert msg['data'] == b'\x01\x02\x03' - - def test_can_inject_message(self): - from eosim.engine.native.peripherals.buses import CANBusController - can = CANBusController('can', 0x1000) - can.inject_message(0x200, b'\xAA\xBB') - assert can.rx_count == 1 - msg = can.receive_message() - assert msg['id'] == 0x200 - - def test_can_filter(self): - from eosim.engine.native.peripherals.buses import CANBusController - can = CANBusController('can', 0x1000) - can.filters = [0x100] - can.inject_message(0x100, b'\x01') - can.inject_message(0x200, b'\x02') - assert can.rx_count == 1 - - def test_modbus_registers(self): - from eosim.engine.native.peripherals.buses import ModbusController - mb = ModbusController('mb', 0x2000) - mb.write_holding(0, [100, 200, 300]) - assert mb.read_holding(0, 3) == [100, 200, 300] - assert mb.transaction_count == 1 - - def test_modbus_coils(self): - from eosim.engine.native.peripherals.buses import ModbusController - mb = ModbusController('mb', 0x2000) - mb.write_coil(0, True) - assert mb.read_coil(0) is True - assert mb.read_coil(1) is False - - def test_arinc429_word(self): - from eosim.engine.native.peripherals.buses import ARINC429 - a = ARINC429('a', 0x3000) - a.send_word(0o310, 0, 0x1234, 0) - assert a.tx_count == 1 - - def test_ethernet_mac(self): - from eosim.engine.native.peripherals.buses import EthernetMAC - eth = EthernetMAC('eth', 0x4000) - eth.send_packet(b'\xFF' * 64) - assert eth.tx_packets == 1 - assert eth.tx_bytes == 64 - - -class TestSimulators: - """Verify each simulator creates correct peripherals and produces valid state.""" - - def _create_vm(self): - from eosim.engine.native import VirtualMachine - return VirtualMachine(name="test", arch="arm", ram_mb=32) - - def test_vehicle_simulator(self): - from eosim.engine.native.simulators import VehicleSimulator - vm = self._create_vm() - sim = VehicleSimulator(vm) - sim.setup() - assert 'can0' in vm.peripherals - assert 'imu0' in vm.peripherals - assert 'steering' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'speed_kmh' in state - assert 'soc_pct' in state - - def test_drone_simulator(self): - from eosim.engine.native.simulators import DroneSimulator - vm = self._create_vm() - sim = DroneSimulator(vm) - sim.setup() - assert 'esc0' in vm.peripherals - assert 'baro0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'altitude_m' in state - assert 'flight_mode' in state - assert state['flight_mode'] == 'DISARMED' - - def test_robot_simulator(self): - from eosim.engine.native.simulators import RobotSimulator - vm = self._create_vm() - sim = RobotSimulator(vm) - sim.setup() - assert 'servo0' in vm.peripherals - assert 'prox0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'joint_angles' in state - assert len(state['joint_angles']) == 6 - - def test_aircraft_simulator(self): - from eosim.engine.native.simulators import AircraftSimulator - vm = self._create_vm() - sim = AircraftSimulator(vm) - sim.setup() - assert 'arinc0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'altitude_ft' in state - assert 'airspeed_kts' in state - - def test_medical_simulator(self): - from eosim.engine.native.simulators import MedicalSimulator - vm = self._create_vm() - sim = MedicalSimulator(vm) - sim.setup() - assert 'ecg0' in vm.peripherals - assert 'spo2_0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'heart_rate' in state - assert 'spo2' in state - assert state['heart_rate'] == 72 - - def test_industrial_simulator(self): - from eosim.engine.native.simulators import IndustrialSimulator - vm = self._create_vm() - sim = IndustrialSimulator(vm) - sim.setup() - assert 'modbus0' in vm.peripherals - assert 'relay0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'conveyor_speed' in state - - def test_iot_simulator(self): - from eosim.engine.native.simulators import IoTSimulator - vm = self._create_vm() - sim = IoTSimulator(vm) - sim.setup() - assert 'wifi0' in vm.peripherals - assert 'temp0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'temperature' in state - - def test_wearable_simulator(self): - from eosim.engine.native.simulators import WearableSimulator - vm = self._create_vm() - sim = WearableSimulator(vm) - sim.setup() - assert 'display0' in vm.peripherals - assert 'haptic0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'heart_rate' in state - assert 'steps' in state - - def test_satellite_simulator(self): - from eosim.engine.native.simulators import SatelliteSimulator - vm = self._create_vm() - sim = SatelliteSimulator(vm) - sim.setup() - assert 'rf0' in vm.peripherals - assert 'crypto0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'solar_power_w' in state - assert 'orbit_alt_km' in state - - def test_energy_simulator(self): - from eosim.engine.native.simulators import EnergySimulator - vm = self._create_vm() - sim = EnergySimulator(vm) - sim.setup() - assert 'solar_adc' in vm.peripherals - assert 'modbus0' in vm.peripherals - sim.tick() - state = sim.get_state() - assert 'solar_power_w' in state - assert 'battery_soc' in state - - def test_simulator_tick_increments(self): - from eosim.engine.native.simulators import VehicleSimulator - vm = self._create_vm() - sim = VehicleSimulator(vm) - sim.setup() - assert sim.tick_count == 0 - sim.tick() - sim.tick() - sim.tick() - assert sim.tick_count == 3 - - def test_simulator_reset(self): - from eosim.engine.native.simulators import DroneSimulator - vm = self._create_vm() - sim = DroneSimulator(vm) - sim.setup() - sim.tick() - sim.tick() - sim.reset() - assert sim.tick_count == 0 - assert sim.state == {} - - -class TestSimulatorFactory: - """Verify factory maps all product types to correct simulator.""" - - def test_factory_creates_vehicle(self): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import SimulatorFactory, VehicleSimulator - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create("automotive_ecu", vm) - assert isinstance(sim, VehicleSimulator) - - def test_factory_creates_drone(self): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import DroneSimulator, SimulatorFactory - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create("drone_controller", vm) - assert isinstance(sim, DroneSimulator) - - def test_factory_creates_medical(self): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import MedicalSimulator, SimulatorFactory - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create("medical_monitor", vm) - assert isinstance(sim, MedicalSimulator) - - def test_factory_creates_robot(self): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import RobotSimulator, SimulatorFactory - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create("robot_controller", vm) - assert isinstance(sim, RobotSimulator) - - def test_factory_fallback_generic(self): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import BaseSimulator, SimulatorFactory - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create("unknown_product", vm) - assert isinstance(sim, BaseSimulator) - - def test_factory_all_product_types_mapped(self): - from eosim.engine.native.simulators import SIMULATOR_MAP - from eosim.gui.product_templates import PRODUCT_CATALOG - for key in PRODUCT_CATALOG: - assert key in SIMULATOR_MAP, f"Product '{key}' not in SIMULATOR_MAP" - - def test_factory_list_simulators(self): - from eosim.engine.native.simulators import SimulatorFactory - sims = SimulatorFactory.list_simulators() - assert 'vehicle' in sims - assert 'drone' in sims - assert 'medical' in sims - assert 'robot' in sims - assert 'generic' in sims - - -class TestSimulatorApp: - """Verify VM creation from build config and basic lifecycle.""" - - def test_vm_creation_from_config(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="test-iot", arch="arm", ram_mb=32) - assert vm.name == "test-iot" - assert vm.arch == "arm" - assert not vm.running - assert "uart0" in vm.peripherals - assert "gpio0" in vm.peripherals - assert "timer0" in vm.peripherals - assert "spi0" in vm.peripherals - assert "i2c0" in vm.peripherals - assert "nvic" in vm.peripherals - - def test_vm_run_stop_lifecycle(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="lifecycle-test", arch="arm", ram_mb=16) - result = vm.run(max_cycles=100, timeout_s=5.0) - assert result["success"] - assert result["cycles"] == 100 - assert not vm.running - assert "boot_log" in result - - def test_vm_step(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="step-test", arch="arm", ram_mb=16) - initial_pc = vm.cpu.state.pc - vm.cpu.step() - assert vm.cpu.state.pc == initial_pc + 4 - assert vm.cpu.state.cycles == 1 - - def test_vm_uart_output(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="uart-test", arch="arm", ram_mb=16) - vm.run(max_cycles=50, timeout_s=5.0) - uart_out = vm.get_uart_output() - assert "EoSim Virtual Machine" in uart_out - assert "Booting" in uart_out - - def test_vm_gpio_interaction(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="gpio-test", arch="arm", ram_mb=16) - gpio = vm.peripherals["gpio0"] - assert gpio.input_val == 0 - gpio.set_input(5, True) - assert gpio.input_val & (1 << 5) - gpio.set_input(5, False) - assert not (gpio.input_val & (1 << 5)) - - def test_vm_peripheral_count(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="periph-test", arch="arm", ram_mb=16) - assert len(vm.peripherals) == 6 - - def test_memory_bus_dump(self): - from eosim.engine.native import VirtualMachine - vm = VirtualMachine(name="mem-test", arch="arm", ram_mb=16) - dump = vm.bus.dump(0x20000000, 32) - assert "20000000:" in dump - - def test_simulator_app_build_and_run(self): - from eosim.gui.simulator_app import SimulatorApp - app = SimulatorApp() - vm = app.build_and_run("automotive_ecu") - assert vm is not None - assert app.running - assert app.product_type == "automotive_ecu" - assert 'can0' in vm.peripherals - app.tick() - state = app.get_state() - assert 'speed_kmh' in state - - def test_simulator_app_run_cycles(self): - from eosim.gui.simulator_app import SimulatorApp - app = SimulatorApp() - app.build_and_run("drone_controller") - app.run_cycles(50) - assert app.tick_count == 50 - - def test_simulator_app_stop_reset(self): - from eosim.gui.simulator_app import SimulatorApp - app = SimulatorApp() - app.build_and_run("medical_monitor") - app.run_cycles(10) - app.stop() - assert not app.running - app.reset() - assert app.tick_count == 0 - - def test_simulator_app_status_text(self): - from eosim.gui.simulator_app import SimulatorApp - app = SimulatorApp() - assert app.get_status_text() == 'No simulation active' - app.build_and_run("iot_sensor") - text = app.get_status_text() - assert 'IoT' in text - - def test_simulator_app_peripheral_names(self): - from eosim.gui.simulator_app import SimulatorApp - app = SimulatorApp() - app.build_and_run("industrial_plc") - names = app.get_peripheral_names() - assert 'modbus0' in names - assert 'relay0' in names - - -class TestCPUPanel: - """Verify CPUPanel.update_state works with both dicts and objects.""" - - @pytest.fixture(autouse=True) - def _skip_no_tk(self): - tk = pytest.importorskip("tkinter") - import os - import sys - if sys.platform.startswith("linux") and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): - pytest.skip("No display available for tkinter on headless Linux") - try: - root = tk.Tk() - root.destroy() - except tk.TclError: - pytest.skip("Tk runtime not available") - - def test_update_state_from_dict(self): - """CPUPanel.update_state should accept a dict and store values.""" - import tkinter as tk - - from eosim.gui.widgets.cpu_panel import CPUPanel - root = tk.Tk() - root.withdraw() - try: - panel = CPUPanel(root) - state = { - 'regs': [i * 100 for i in range(16)], - 'pc': 0x08001000, - 'sp': 0x20010000, - 'lr': 0x08000800, - 'cpsr': 0x60000013, - 'cycles': 42, - 'mode': 'user', - 'halted': False, - } - panel.update_state(state) - assert panel._prev_pc == 0x08001000 - assert panel._prev_sp == 0x20010000 - assert panel._prev_lr == 0x08000800 - assert panel._prev_cpsr == 0x60000013 - assert panel._prev_regs[0] == 0 - assert panel._prev_regs[5] == 500 - finally: - root.destroy() - - def test_update_state_from_cpu_state_object(self): - """CPUPanel.update_state should accept a CPUState-like object.""" - import tkinter as tk - - from eosim.gui.widgets.cpu_panel import CPUPanel - root = tk.Tk() - root.withdraw() - try: - panel = CPUPanel(root) - - class FakeCPU: - regs = [0] * 16 - pc = 0x08000000 - sp = 0x20000000 - lr = 0 - cpsr = 0 - cycles = 10 - mode = 'supervisor' - halted = False - - panel.update_state(FakeCPU()) - assert panel._prev_pc == 0x08000000 - assert panel._prev_regs == [0] * 16 - finally: - root.destroy() - - def test_reset_clears_state(self): - """CPUPanel.reset should zero out all previous values.""" - import tkinter as tk - - from eosim.gui.widgets.cpu_panel import CPUPanel - root = tk.Tk() - root.withdraw() - try: - panel = CPUPanel(root) - state = { - 'regs': [999] * 16, 'pc': 0xDEAD, 'sp': 0xBEEF, - 'lr': 0xCAFE, 'cpsr': 0xF0000000, 'cycles': 100, - } - panel.update_state(state) - assert panel._prev_pc == 0xDEAD - panel.reset() - assert panel._prev_pc == 0 - assert panel._prev_sp == 0 - assert panel._prev_regs == [0] * 16 - finally: - root.destroy() - - -class TestTkPeripheralPanel: - """Verify domain sub-panel data flow for each simulator type.""" - - def _create_sim(self, product_type): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import SimulatorFactory - vm = VirtualMachine(name="test", arch="arm", ram_mb=32) - sim = SimulatorFactory.create(product_type, vm) - return vm, sim - - def test_automotive_state_keys(self): - vm, sim = self._create_sim("automotive_ecu") - sim.tick() - state = sim.get_state() - assert 'speed_kmh' in state - assert 'steering_deg' in state - assert 'soc_pct' in state - assert 'can0' in vm.peripherals - can = vm.peripherals['can0'] - assert hasattr(can, 'tx_count') - - def test_drone_state_keys(self): - vm, sim = self._create_sim("drone_controller") - sim.tick() - state = sim.get_state() - assert 'motor_rpm' in state - assert len(state['motor_rpm']) == 4 - assert 'altitude_m' in state - assert 'flight_mode' in state - assert 'roll_deg' in state - - def test_medical_state_keys(self): - vm, sim = self._create_sim("medical_monitor") - for _ in range(10): - sim.tick() - state = sim.get_state() - assert 'heart_rate' in state - assert 'spo2' in state - assert 'temperature' in state - assert 'alarm' in state - assert 'ecg_waveform' in state - - def test_robot_state_keys(self): - vm, sim = self._create_sim("robot_controller") - sim.tick() - state = sim.get_state() - assert 'joint_angles' in state - assert len(state['joint_angles']) == 6 - assert 'gripper_open' in state - - def test_aircraft_state_keys(self): - vm, sim = self._create_sim("fixed_wing") - sim.tick() - state = sim.get_state() - assert 'altitude_ft' in state - assert 'airspeed_kts' in state - assert 'heading_deg' in state - - def test_industrial_state_keys(self): - vm, sim = self._create_sim("industrial_plc") - sim.tick() - state = sim.get_state() - assert 'conveyor_speed' in state - assert 'modbus0' in vm.peripherals - - def test_iot_state_keys(self): - vm, sim = self._create_sim("iot_sensor") - sim.tick() - state = sim.get_state() - assert 'temperature' in state - - def test_satellite_state_keys(self): - vm, sim = self._create_sim("cubesat") - sim.tick() - state = sim.get_state() - assert 'solar_power_w' in state - assert 'orbit_alt_km' in state - - def test_energy_state_keys(self): - vm, sim = self._create_sim("solar_inverter") - sim.tick() - state = sim.get_state() - assert 'solar_power_w' in state - assert 'battery_soc' in state - - def test_wearable_state_keys(self): - vm, sim = self._create_sim("wearable_device") - sim.tick() - state = sim.get_state() - assert 'heart_rate' in state - assert 'steps' in state - - def test_peripheral_panel_configure(self): - """PeripheralPanel should create sub-panels for all VM peripherals.""" - from eosim.gui.widgets.peripheral_panel import PeripheralPanel - vm, sim = self._create_sim("automotive_ecu") - panel = PeripheralPanel() - panel.configure_for_product(vm, 'automotive') - assert len(panel.sub_panels) > 0 - result = panel.update(vm) - assert isinstance(result, dict) - assert len(result) > 0 - - -class TestScenarioLoading: - """Verify each simulator's load_scenario changes state correctly.""" - - def _create_sim(self, product_type): - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import SimulatorFactory - vm = VirtualMachine(name="test", arch="arm", ram_mb=32) - sim = SimulatorFactory.create(product_type, vm) - return vm, sim - - def test_vehicle_highway_cruise(self): - vm, sim = self._create_sim("automotive_ecu") - sim.load_scenario('highway_cruise') - assert sim.scenario == 'highway_cruise' - assert sim.state.get('scenario') == 'highway_cruise' - for _ in range(50): - sim.tick() - assert sim.state['speed_kmh'] > 0 - - def test_vehicle_emergency_braking(self): - vm, sim = self._create_sim("automotive_ecu") - sim.load_scenario('highway_cruise') - for _ in range(100): - sim.tick() - speed_before = sim.state['speed_kmh'] - sim.load_scenario('emergency_braking') - for _ in range(100): - sim.tick() - assert sim.state['speed_kmh'] <= speed_before - - def test_drone_takeoff(self): - vm, sim = self._create_sim("drone_controller") - sim.load_scenario('takeoff') - assert sim.state.get('flight_mode') != 'DISARMED' - for _ in range(100): - sim.tick() - assert sim.state['altitude_m'] > 0 - - def test_drone_motor_failure(self): - vm, sim = self._create_sim("drone_controller") - sim.load_scenario('motor_failure') - assert sim.state.get('motor_failure') == 2 - - def test_medical_alarm_trigger(self): - vm, sim = self._create_sim("medical_monitor") - sim.load_scenario('alarm_trigger') - assert sim.state.get('scenario') == 'alarm_trigger' - for _ in range(10): - sim.tick() - assert sim.state['heart_rate'] > 100 - - def test_medical_sensor_disconnect(self): - vm, sim = self._create_sim("medical_monitor") - sim.load_scenario('sensor_disconnect') - for _ in range(50): - sim.tick() - assert sim.state.get('sensor_connected') is False - - def test_all_simulators_have_scenarios(self): - """Every non-base simulator should have a SCENARIOS dict.""" - from eosim.engine.native.simulators import ( - AircraftSimulator, - DroneSimulator, - EnergySimulator, - IndustrialSimulator, - IoTSimulator, - MedicalSimulator, - RobotSimulator, - SatelliteSimulator, - VehicleSimulator, - WearableSimulator, - ) - for cls in [VehicleSimulator, DroneSimulator, MedicalSimulator, - RobotSimulator, AircraftSimulator, IndustrialSimulator, - IoTSimulator, SatelliteSimulator, EnergySimulator, - WearableSimulator]: - assert hasattr(cls, 'SCENARIOS'), f"{cls.__name__} missing SCENARIOS" - assert len(cls.SCENARIOS) > 0, f"{cls.__name__} has empty SCENARIOS" - - def test_all_simulators_load_scenario(self): - """Every simulator's load_scenario should set scenario name in state.""" - from eosim.engine.native import VirtualMachine - from eosim.engine.native.simulators import SIMULATOR_MAP, SimulatorFactory - tested = set() - for product_type, cls in SIMULATOR_MAP.items(): - if cls.__name__ in tested or cls.__name__ == 'BaseSimulator': - continue - tested.add(cls.__name__) - vm = VirtualMachine(name="t", arch="arm", ram_mb=32) - sim = SimulatorFactory.create(product_type, vm) - scenarios = getattr(sim, 'SCENARIOS', {}) - if scenarios: - first = list(scenarios.keys())[0] - sim.load_scenario(first) - assert sim.state.get('scenario') == first, ( - f"{cls.__name__}.load_scenario('{first}') " - f"didn't set state['scenario']" - ) +# SPDX-License-Identifier: MIT +"""Tests for EoSim GUI components — expanded for multi-product simulator.""" +import pytest + +# Minimal ARM image: MOV r0,#1 then UDF (halt). Needed because +# VirtualMachine.run() now refuses to report a boot when no firmware is +# loaded - see tests/unit/test_native_engine_execution.py. +def _tiny_arm_image() -> bytes: + import struct + return b"".join(struct.pack("= 79 + + def test_all_template_names(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + # Check core templates still exist (subset check, not exact match) + core_templates = { + "iot_sensor", "smart_home_hub", "automotive_ecu", + "ev_powertrain", "adas_controller", + "medical_monitor", "drone_controller", "industrial_plc", + "wearable_device", "robot_controller", + "fixed_wing", "cubesat", "solar_inverter", + } + assert core_templates.issubset(set(PRODUCT_CATALOG.keys())) + + def test_templates_have_required_fields(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + required = [ + "name", "display_name", "icon", "arch", "ram_mb", + "peripherals", "domain", "modeling", "description", + "default_platform", + ] + for key, tpl in PRODUCT_CATALOG.items(): + for field in required: + assert hasattr(tpl, field), f"Template '{key}' missing '{field}'" + val = getattr(tpl, field) + if field == "ram_mb": + assert val > 0, f"{key}.ram_mb must be > 0" + elif field == "peripherals": + assert isinstance(val, list) + assert len(val) > 0, f"{key} must have peripherals" + else: + assert val, f"Template '{key}' field '{field}' is empty" + + def test_simulator_class_field(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + for key, tpl in PRODUCT_CATALOG.items(): + assert hasattr(tpl, 'simulator_class'), f"{key} missing simulator_class" + assert tpl.simulator_class, f"{key} simulator_class is empty" + + def test_valid_arch(self): + from eosim.core.schema import VALID_ARCHES + from eosim.gui.product_templates import PRODUCT_CATALOG + for key, tpl in PRODUCT_CATALOG.items(): + assert tpl.arch in VALID_ARCHES, f"{key} invalid arch: {tpl.arch}" + + def test_valid_domain(self): + from eosim.core.schema import VALID_DOMAINS + from eosim.gui.product_templates import PRODUCT_CATALOG + for key, tpl in PRODUCT_CATALOG.items(): + assert tpl.domain in VALID_DOMAINS, f"{key} invalid domain: {tpl.domain}" + + def test_valid_modeling(self): + from eosim.core.schema import VALID_MODELING + from eosim.gui.product_templates import PRODUCT_CATALOG + for key, tpl in PRODUCT_CATALOG.items(): + assert tpl.modeling in VALID_MODELING, f"{key} invalid modeling" + + def test_valid_peripherals(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + from eosim.gui.widgets.build_panel import ALL_PERIPHERALS + for key, tpl in PRODUCT_CATALOG.items(): + for p in tpl.peripherals: + assert p in ALL_PERIPHERALS, f"{key} invalid peripheral: {p}" + + def test_get_template(self): + from eosim.gui.product_templates import get_template + tpl = get_template("iot_sensor") + assert tpl is not None + assert tpl.name == "iot_sensor" + assert get_template("nonexistent") is None + + def test_list_templates(self): + from eosim.gui.product_templates import list_templates + names = list_templates() + assert len(names) >= 79 + assert names == sorted(names) + + +class TestBuildPanel: + """Verify build config generation from product templates.""" + + def test_iot_sensor_defaults(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + tpl = PRODUCT_CATALOG["iot_sensor"] + assert tpl.arch == "arm" + assert tpl.ram_mb == 64 + assert "uart" in tpl.peripherals + assert "gpio" in tpl.peripherals + assert "i2c" in tpl.peripherals + assert tpl.domain == "iot" + + def test_all_templates_have_valid_config(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + for key, tpl in PRODUCT_CATALOG.items(): + assert isinstance(tpl.arch, str) and tpl.arch + assert isinstance(tpl.ram_mb, int) and tpl.ram_mb > 0 + assert isinstance(tpl.peripherals, list) and len(tpl.peripherals) > 0 + + def test_robot_controller_is_arm64(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + tpl = PRODUCT_CATALOG["robot_controller"] + assert tpl.arch == "arm64" + assert tpl.ram_mb == 512 + + def test_wearable_is_smallest_ram(self): + from eosim.gui.product_templates import PRODUCT_CATALOG + tpl = PRODUCT_CATALOG["wearable_device"] + assert tpl.ram_mb == 32 + all_rams = [t.ram_mb for t in PRODUCT_CATALOG.values()] + assert tpl.ram_mb == min(all_rams) + + def test_build_panel_select_product(self): + from eosim.gui.widgets.build_panel import BuildPanel + bp = BuildPanel() + assert bp.select_product("automotive_ecu") + config = bp.get_build_config() + assert config['product'] == 'automotive_ecu' + assert 'can' in config['peripherals'] + assert config['arch'] == 'arm' + + def test_build_panel_toggle_peripheral(self): + from eosim.gui.widgets.build_panel import BuildPanel + bp = BuildPanel() + bp.select_product("iot_sensor") + assert bp.toggle_peripheral("wifi") is True + config = bp.get_build_config() + assert 'wifi' in config['peripherals'] + assert bp.toggle_peripheral("wifi") is False + config = bp.get_build_config() + assert 'wifi' not in config['peripherals'] + + def test_build_panel_peripheral_groups(self): + from eosim.gui.widgets.build_panel import BuildPanel + bp = BuildPanel() + bp.select_product("drone_controller") + groups = bp.get_peripheral_groups() + assert 'Core' in groups + assert 'Sensors' in groups + assert 'Actuators' in groups + assert 'Buses' in groups + assert 'Wireless' in groups + assert 'Composite' in groups + + def test_build_panel_list_products(self): + from eosim.gui.widgets.build_panel import BuildPanel + bp = BuildPanel() + products = bp.list_products() + assert len(products) >= 79 + names = [p['name'] for p in products] + assert names == sorted(names) + + +class TestSensors: + """Verify sensor simulate_tick(), register reads, and value injection.""" + + def test_temperature_sensor_tick(self): + from eosim.engine.native.peripherals.sensors import TemperatureSensor + s = TemperatureSensor('t', 0x1000) + for _ in range(100): + s.simulate_tick() + assert s._tick_count == 100 + assert s.read_reg(0x00) == int(s.temperature * 100) & 0xFFFFFFFF + + def test_temperature_sensor_set_value(self): + from eosim.engine.native.peripherals.sensors import TemperatureSensor + s = TemperatureSensor('t', 0x1000) + s.set_value(42.5, 80.0) + assert s.temperature == 42.5 + assert s.humidity == 80.0 + + def test_imu_sensor_axes(self): + from eosim.engine.native.peripherals.sensors import IMUSensor + imu = IMUSensor('imu', 0x2000, 9) + imu.set_accel(1.0, 2.0, 9.81) + assert imu.accel == [1.0, 2.0, 9.81] + assert imu.read_reg(0x00) == 1000 + assert imu.read_reg(0x04) == 2000 + + def test_gps_module_position(self): + from eosim.engine.native.peripherals.sensors import GPSModule + gps = GPSModule('gps', 0x3000) + gps.set_position(40.7128, -74.0060, 10) + assert gps.latitude == 40.7128 + assert gps.longitude == -74.0060 + + def test_proximity_sensor(self): + from eosim.engine.native.peripherals.sensors import ProximitySensor + p = ProximitySensor('prox', 0x4000, max_range_cm=400) + p.set_value(50.0) + assert p.distance_cm == 50.0 + assert p.detected is True + + def test_ecg_sensor_waveform(self): + from eosim.engine.native.peripherals.sensors import ECGSensor + ecg = ECGSensor('ecg', 0x5000) + ecg.set_heart_rate(100) + assert ecg.heart_rate_bpm == 100 + for _ in range(50): + ecg.simulate_tick() + assert len(ecg.waveform) == 256 + assert ecg.read_reg(0x00) == 100 + + def test_pulse_oximeter(self): + from eosim.engine.native.peripherals.sensors import PulseOximeter + spo2 = PulseOximeter('spo2', 0x6000) + spo2.set_value(95.0, 80) + assert spo2.spo2_percent == 95.0 + assert spo2.pulse_rate == 80 + + def test_adc_channel(self): + from eosim.engine.native.peripherals.sensors import ADCChannel + adc = ADCChannel('adc', 0x7000, channels=4, resolution=12) + adc.set_channel(0, 2048) + assert adc.values[0] == 2048 + assert adc.read_reg(0x00) == 2048 + + def test_pressure_sensor_altitude(self): + from eosim.engine.native.peripherals.sensors import PressureSensor + baro = PressureSensor('baro', 0x8000) + baro.set_altitude(1000) + assert abs(baro.altitude_m - 1000) < 1 + + def test_sensor_io_handler(self): + from eosim.engine.native.peripherals.sensors import TemperatureSensor + s = TemperatureSensor('t', 0x1000) + s.set_value(25.0) + val = s.io_handler('read', 0x1000, 0) + assert val == int(25.0 * 100) + + +class TestActuators: + """Verify motor/servo/ESC command response and state.""" + + def test_motor_controller_enable(self): + from eosim.engine.native.peripherals.actuators import MotorController + m = MotorController('m', 0x1000) + m.enabled = True + m.target_speed = 1000 + for _ in range(50): + m.simulate_tick() + assert m.speed_rpm > 0 + + def test_servo_controller_target(self): + from eosim.engine.native.peripherals.actuators import ServoController + s = ServoController('s', 0x2000, channels=4) + s.set_target(0, 45.0) + assert s.targets[0] == 45.0 + for _ in range(100): + s.simulate_tick() + assert abs(s.positions[0] - 45.0) < 5.0 + + def test_esc_controller_armed(self): + from eosim.engine.native.peripherals.actuators import ESCController + esc = ESCController('esc', 0x3000, channels=4) + esc.armed = True + esc.enabled = True + esc.throttle = [50.0, 50.0, 50.0, 50.0] + for _ in range(50): + esc.simulate_tick() + for rpm in esc.rpm: + assert rpm > 0 + + def test_brake_actuator(self): + from eosim.engine.native.peripherals.actuators import BrakeActuator + b = BrakeActuator('b', 0x4000) + b.target_pct = 80.0 + for _ in range(20): + b.simulate_tick() + assert b.pressure_pct > 50.0 + + def test_steering_actuator(self): + from eosim.engine.native.peripherals.actuators import SteeringActuator + s = SteeringActuator('s', 0x5000) + s.target_angle = 30.0 + for _ in range(20): + s.simulate_tick() + assert abs(s.angle_deg - 30.0) < 2.0 + + def test_relay_bank_toggle(self): + from eosim.engine.native.peripherals.actuators import RelayBank + r = RelayBank('r', 0x6000, channels=4) + r.write_reg(0x00, 0b0101) + assert r.states[0] is True + assert r.states[1] is False + assert r.states[2] is True + + def test_pump_controller(self): + from eosim.engine.native.peripherals.actuators import PumpController + p = PumpController('p', 0x7000) + p.enabled = True + p.target_flow = 100.0 + for _ in range(50): + p.simulate_tick() + assert p.flow_rate_ml_min > 50.0 + + def test_display_driver(self): + from eosim.engine.native.peripherals.actuators import DisplayDriver + d = DisplayDriver('d', 0x8000, 128, 64) + assert d.width == 128 + assert d.height == 64 + assert len(d.framebuffer) == 128 * 64 // 8 + + +class TestBuses: + """Verify CAN message send/receive, Modbus register read/write.""" + + def test_can_send_receive(self): + from eosim.engine.native.peripherals.buses import CANBusController + can = CANBusController('can', 0x1000) + can.loopback = True + can.send_message(0x100, b'\x01\x02\x03') + assert can.tx_count == 1 + msg = can.receive_message() + assert msg is not None + assert msg['id'] == 0x100 + assert msg['data'] == b'\x01\x02\x03' + + def test_can_inject_message(self): + from eosim.engine.native.peripherals.buses import CANBusController + can = CANBusController('can', 0x1000) + can.inject_message(0x200, b'\xAA\xBB') + assert can.rx_count == 1 + msg = can.receive_message() + assert msg['id'] == 0x200 + + def test_can_filter(self): + from eosim.engine.native.peripherals.buses import CANBusController + can = CANBusController('can', 0x1000) + can.filters = [0x100] + can.inject_message(0x100, b'\x01') + can.inject_message(0x200, b'\x02') + assert can.rx_count == 1 + + def test_modbus_registers(self): + from eosim.engine.native.peripherals.buses import ModbusController + mb = ModbusController('mb', 0x2000) + mb.write_holding(0, [100, 200, 300]) + assert mb.read_holding(0, 3) == [100, 200, 300] + assert mb.transaction_count == 1 + + def test_modbus_coils(self): + from eosim.engine.native.peripherals.buses import ModbusController + mb = ModbusController('mb', 0x2000) + mb.write_coil(0, True) + assert mb.read_coil(0) is True + assert mb.read_coil(1) is False + + def test_arinc429_word(self): + from eosim.engine.native.peripherals.buses import ARINC429 + a = ARINC429('a', 0x3000) + a.send_word(0o310, 0, 0x1234, 0) + assert a.tx_count == 1 + + def test_ethernet_mac(self): + from eosim.engine.native.peripherals.buses import EthernetMAC + eth = EthernetMAC('eth', 0x4000) + eth.send_packet(b'\xFF' * 64) + assert eth.tx_packets == 1 + assert eth.tx_bytes == 64 + + +class TestSimulators: + """Verify each simulator creates correct peripherals and produces valid state.""" + + def _create_vm(self): + from eosim.engine.native import VirtualMachine + return VirtualMachine(name="test", arch="arm", ram_mb=32) + + def test_vehicle_simulator(self): + from eosim.engine.native.simulators import VehicleSimulator + vm = self._create_vm() + sim = VehicleSimulator(vm) + sim.setup() + assert 'can0' in vm.peripherals + assert 'imu0' in vm.peripherals + assert 'steering' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'speed_kmh' in state + assert 'soc_pct' in state + + def test_drone_simulator(self): + from eosim.engine.native.simulators import DroneSimulator + vm = self._create_vm() + sim = DroneSimulator(vm) + sim.setup() + assert 'esc0' in vm.peripherals + assert 'baro0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'altitude_m' in state + assert 'flight_mode' in state + assert state['flight_mode'] == 'DISARMED' + + def test_robot_simulator(self): + from eosim.engine.native.simulators import RobotSimulator + vm = self._create_vm() + sim = RobotSimulator(vm) + sim.setup() + assert 'servo0' in vm.peripherals + assert 'prox0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'joint_angles' in state + assert len(state['joint_angles']) == 6 + + def test_aircraft_simulator(self): + from eosim.engine.native.simulators import AircraftSimulator + vm = self._create_vm() + sim = AircraftSimulator(vm) + sim.setup() + assert 'arinc0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'altitude_ft' in state + assert 'airspeed_kts' in state + + def test_medical_simulator(self): + from eosim.engine.native.simulators import MedicalSimulator + vm = self._create_vm() + sim = MedicalSimulator(vm) + sim.setup() + assert 'ecg0' in vm.peripherals + assert 'spo2_0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'heart_rate' in state + assert 'spo2' in state + assert state['heart_rate'] == 72 + + def test_industrial_simulator(self): + from eosim.engine.native.simulators import IndustrialSimulator + vm = self._create_vm() + sim = IndustrialSimulator(vm) + sim.setup() + assert 'modbus0' in vm.peripherals + assert 'relay0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'conveyor_speed' in state + + def test_iot_simulator(self): + from eosim.engine.native.simulators import IoTSimulator + vm = self._create_vm() + sim = IoTSimulator(vm) + sim.setup() + assert 'wifi0' in vm.peripherals + assert 'temp0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'temperature' in state + + def test_wearable_simulator(self): + from eosim.engine.native.simulators import WearableSimulator + vm = self._create_vm() + sim = WearableSimulator(vm) + sim.setup() + assert 'display0' in vm.peripherals + assert 'haptic0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'heart_rate' in state + assert 'steps' in state + + def test_satellite_simulator(self): + from eosim.engine.native.simulators import SatelliteSimulator + vm = self._create_vm() + sim = SatelliteSimulator(vm) + sim.setup() + assert 'rf0' in vm.peripherals + assert 'crypto0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'solar_power_w' in state + assert 'orbit_alt_km' in state + + def test_energy_simulator(self): + from eosim.engine.native.simulators import EnergySimulator + vm = self._create_vm() + sim = EnergySimulator(vm) + sim.setup() + assert 'solar_adc' in vm.peripherals + assert 'modbus0' in vm.peripherals + sim.tick() + state = sim.get_state() + assert 'solar_power_w' in state + assert 'battery_soc' in state + + def test_simulator_tick_increments(self): + from eosim.engine.native.simulators import VehicleSimulator + vm = self._create_vm() + sim = VehicleSimulator(vm) + sim.setup() + assert sim.tick_count == 0 + sim.tick() + sim.tick() + sim.tick() + assert sim.tick_count == 3 + + def test_simulator_reset(self): + from eosim.engine.native.simulators import DroneSimulator + vm = self._create_vm() + sim = DroneSimulator(vm) + sim.setup() + sim.tick() + sim.tick() + sim.reset() + assert sim.tick_count == 0 + assert sim.state == {} + + +class TestSimulatorFactory: + """Verify factory maps all product types to correct simulator.""" + + def test_factory_creates_vehicle(self): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import SimulatorFactory, VehicleSimulator + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create("automotive_ecu", vm) + assert isinstance(sim, VehicleSimulator) + + def test_factory_creates_drone(self): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import DroneSimulator, SimulatorFactory + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create("drone_controller", vm) + assert isinstance(sim, DroneSimulator) + + def test_factory_creates_medical(self): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import MedicalSimulator, SimulatorFactory + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create("medical_monitor", vm) + assert isinstance(sim, MedicalSimulator) + + def test_factory_creates_robot(self): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import RobotSimulator, SimulatorFactory + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create("robot_controller", vm) + assert isinstance(sim, RobotSimulator) + + def test_factory_fallback_generic(self): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import BaseSimulator, SimulatorFactory + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create("unknown_product", vm) + assert isinstance(sim, BaseSimulator) + + def test_factory_all_product_types_mapped(self): + from eosim.engine.native.simulators import SIMULATOR_MAP + from eosim.gui.product_templates import PRODUCT_CATALOG + for key in PRODUCT_CATALOG: + assert key in SIMULATOR_MAP, f"Product '{key}' not in SIMULATOR_MAP" + + def test_factory_list_simulators(self): + from eosim.engine.native.simulators import SimulatorFactory + sims = SimulatorFactory.list_simulators() + assert 'vehicle' in sims + assert 'drone' in sims + assert 'medical' in sims + assert 'robot' in sims + assert 'generic' in sims + + +class TestSimulatorApp: + """Verify VM creation from build config and basic lifecycle.""" + + def test_vm_creation_from_config(self): + from eosim.engine.native import VirtualMachine + vm = VirtualMachine(name="test-iot", arch="arm", ram_mb=32) + assert vm.name == "test-iot" + assert vm.arch == "arm" + assert not vm.running + assert "uart0" in vm.peripherals + assert "gpio0" in vm.peripherals + assert "timer0" in vm.peripherals + assert "spi0" in vm.peripherals + assert "i2c0" in vm.peripherals + assert "nvic" in vm.peripherals + + def test_vm_run_stop_lifecycle(self): + """Runs to the cycle limit and stops cleanly. + + This used to run with no firmware at all, so `cycles == 100` counted + steps over zeroed memory and `success` was the hardcoded True. It now + loads a real NOP sled with no halt instruction: the engine stops it at + the budget, which is a completed run but NOT a success - the program + never chose to finish. + """ + import struct + + from eosim.engine.native import VirtualMachine + nop_sled = struct.pack(" 0 + result = panel.update(vm) + assert isinstance(result, dict) + assert len(result) > 0 + + +class TestScenarioLoading: + """Verify each simulator's load_scenario changes state correctly.""" + + def _create_sim(self, product_type): + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import SimulatorFactory + vm = VirtualMachine(name="test", arch="arm", ram_mb=32) + sim = SimulatorFactory.create(product_type, vm) + return vm, sim + + def test_vehicle_highway_cruise(self): + vm, sim = self._create_sim("automotive_ecu") + sim.load_scenario('highway_cruise') + assert sim.scenario == 'highway_cruise' + assert sim.state.get('scenario') == 'highway_cruise' + for _ in range(50): + sim.tick() + assert sim.state['speed_kmh'] > 0 + + def test_vehicle_emergency_braking(self): + vm, sim = self._create_sim("automotive_ecu") + sim.load_scenario('highway_cruise') + for _ in range(100): + sim.tick() + speed_before = sim.state['speed_kmh'] + sim.load_scenario('emergency_braking') + for _ in range(100): + sim.tick() + assert sim.state['speed_kmh'] <= speed_before + + def test_drone_takeoff(self): + vm, sim = self._create_sim("drone_controller") + sim.load_scenario('takeoff') + assert sim.state.get('flight_mode') != 'DISARMED' + for _ in range(100): + sim.tick() + assert sim.state['altitude_m'] > 0 + + def test_drone_motor_failure(self): + vm, sim = self._create_sim("drone_controller") + sim.load_scenario('motor_failure') + assert sim.state.get('motor_failure') == 2 + + def test_medical_alarm_trigger(self): + vm, sim = self._create_sim("medical_monitor") + sim.load_scenario('alarm_trigger') + assert sim.state.get('scenario') == 'alarm_trigger' + for _ in range(10): + sim.tick() + assert sim.state['heart_rate'] > 100 + + def test_medical_sensor_disconnect(self): + vm, sim = self._create_sim("medical_monitor") + sim.load_scenario('sensor_disconnect') + for _ in range(50): + sim.tick() + assert sim.state.get('sensor_connected') is False + + def test_all_simulators_have_scenarios(self): + """Every non-base simulator should have a SCENARIOS dict.""" + from eosim.engine.native.simulators import ( + AircraftSimulator, + DroneSimulator, + EnergySimulator, + IndustrialSimulator, + IoTSimulator, + MedicalSimulator, + RobotSimulator, + SatelliteSimulator, + VehicleSimulator, + WearableSimulator, + ) + for cls in [VehicleSimulator, DroneSimulator, MedicalSimulator, + RobotSimulator, AircraftSimulator, IndustrialSimulator, + IoTSimulator, SatelliteSimulator, EnergySimulator, + WearableSimulator]: + assert hasattr(cls, 'SCENARIOS'), f"{cls.__name__} missing SCENARIOS" + assert len(cls.SCENARIOS) > 0, f"{cls.__name__} has empty SCENARIOS" + + def test_all_simulators_load_scenario(self): + """Every simulator's load_scenario should set scenario name in state.""" + from eosim.engine.native import VirtualMachine + from eosim.engine.native.simulators import SIMULATOR_MAP, SimulatorFactory + tested = set() + for product_type, cls in SIMULATOR_MAP.items(): + if cls.__name__ in tested or cls.__name__ == 'BaseSimulator': + continue + tested.add(cls.__name__) + vm = VirtualMachine(name="t", arch="arm", ram_mb=32) + sim = SimulatorFactory.create(product_type, vm) + scenarios = getattr(sim, 'SCENARIOS', {}) + if scenarios: + first = list(scenarios.keys())[0] + sim.load_scenario(first) + assert sim.state.get('scenario') == first, ( + f"{cls.__name__}.load_scenario('{first}') " + f"didn't set state['scenario']" + ) diff --git a/tests/unit/test_native_engine_execution.py b/tests/unit/test_native_engine_execution.py new file mode 100644 index 0000000..73ee68d --- /dev/null +++ b/tests/unit/test_native_engine_execution.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +"""Execution and reporting contract for the native engine. + +Two things are pinned here. + +**The CPU really executes.** eosim/engine/native/cpu implements a small ARM32 +subset (MOV imm, B, LDR, STR, BX LR, SVC, UDF). These tests hand-assemble +instructions, run them, and assert the resulting register and memory state, so +"there is a CPU" becomes a measured claim rather than an assumption. + +**A run reports what happened.** VirtualMachine.run() used to return the literal +``success: True`` and print "EoS booted successfully" unconditionally, and +``eosim run `` never loaded any firmware. Stepping over zeroed memory +10000 times therefore printed "PASSED (10000 cycles)" and reported a successful +EoS boot with no EoS involved. These tests fix that behaviour in place: + + no firmware -> success False, reason 'no-firmware', 0 cycles + UDF reached -> success True, reason 'halted' + runaway code -> success False, reason 'cycle-limit' +""" +import struct + +from eosim.engine.native import VirtualMachine + +FLASH = 0x08000000 + + +def arm(*words: int) -> bytes: + """Little-endian ARM word stream.""" + return b"".join(struct.pack(" int: + """MOV Rd, #imm8 — ARM data-processing, no rotate.""" + assert 0 <= rd <= 15 and 0 <= imm8 <= 0xFF + return 0xE3A00000 | (rd << 12) | imm8 + + +NOP = 0x00000000 +UDF = 0xE7FFDEFE # permanently undefined — used here as halt +BX_LR = 0xE12FFF1E + + +def _vm(prog: bytes) -> VirtualMachine: + vm = VirtualMachine(name="test-vm", arch="arm", ram_mb=1) + vm.load_binary(prog, addr=FLASH) + return vm + + +class TestExecution: + def test_mov_immediate_reaches_the_register(self): + vm = _vm(arm(mov_imm(0, 0x2A), mov_imm(1, 0x07), UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert vm.cpu.state.regs[0] == 0x2A + assert vm.cpu.state.regs[1] == 0x07 + assert r["cycles"] == 3 # 2 MOVs + the UDF that halts + + def test_udf_halts_and_is_the_only_success(self): + vm = _vm(arm(mov_imm(0, 1), UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert vm.cpu.state.halted is True + assert r["reason"] == "halted" + assert r["success"] is True + + def test_running_off_the_end_is_not_success(self): + """No halt instruction: the engine stops it, so it did not succeed.""" + vm = _vm(arm(*([NOP] * 8))) + r = vm.run(max_cycles=16, timeout_s=5) + + assert r["reason"] == "cycle-limit" + assert r["success"] is False + assert r["cycles"] == 16 + + def test_cycle_count_matches_instructions_retired(self): + vm = _vm(arm(mov_imm(0, 1), mov_imm(0, 2), mov_imm(0, 3), UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert r["cycles"] == 4 # 3 MOVs + the UDF that halts + assert vm.cpu.state.regs[0] == 3 # last write wins + + +class TestReportingContract: + def test_no_firmware_is_a_failure_not_a_boot(self): + """The regression this suite exists for.""" + vm = VirtualMachine(name="empty", arch="arm", ram_mb=1) + r = vm.run(max_cycles=10000, timeout_s=5) + + assert r["success"] is False + assert r["reason"] == "no-firmware" + assert r["cycles"] == 0 + assert "No firmware loaded" in r["boot_log"] + assert "booted successfully" not in r["boot_log"] + + def test_load_binary_marks_firmware_present(self): + vm = VirtualMachine(name="v", arch="arm", ram_mb=1) + assert vm.firmware_loaded is False + vm.load_binary(arm(UDF), addr=FLASH) + assert vm.firmware_loaded is True + + def test_load_firmware_from_file(self, tmp_path): + img = tmp_path / "fw.bin" + img.write_bytes(arm(mov_imm(2, 0x5A), UDF)) + + vm = VirtualMachine(name="v", arch="arm", ram_mb=1) + assert vm.load_firmware(str(img)) is True + assert vm.firmware_path == str(img) + + r = vm.run(max_cycles=64, timeout_s=5) + assert r["success"] is True + assert vm.cpu.state.regs[2] == 0x5A + + def test_missing_firmware_file_is_rejected(self): + vm = VirtualMachine(name="v", arch="arm", ram_mb=1) + assert vm.load_firmware("/nonexistent/fw.bin") is False + assert vm.firmware_loaded is False + + def test_boot_log_has_real_newlines(self): + """The log was written with escaped \\\\n, so it arrived as literal + backslash-n and every consumer saw one long line.""" + vm = _vm(arm(UDF)) + r = vm.run(max_cycles=8, timeout_s=5) + + assert "\\n" not in r["boot_log"] + assert "\n" in r["boot_log"] + + def test_dump_state_has_real_newlines(self): + vm = _vm(arm(UDF)) + vm.run(max_cycles=8, timeout_s=5) + assert "\\n" not in vm.dump_state() + + +class TestTimeout: + def test_timeout_is_reported_as_such(self): + vm = _vm(arm(*([NOP] * 4))) + r = vm.run(max_cycles=10_000_000, timeout_s=0.05) + + assert r["reason"] in ("timeout", "cycle-limit") + assert r["success"] is False From 18ea2bf76d0fc9b4e110bb96e8762d4702420110 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:52:50 -0700 Subject: [PATCH 2/9] fix(cpu): stop treating undecoded opcodes as no-ops The decoder covers eight ARM32 instructions: MOV imm, B, LDR, STR, BX LR, SVC, UDF and the all-zero word. Anything else fell off the end of the if/elif chain and execution continued as though it had run. That is the same class of defect as the hardcoded success this branch already fixed, and it is worse, because it survives the fix. Measured before the change: MOV r1,#5 ; MOV r2,#3 ; ADD r0,r1,r2 ; UDF -> R0 = 0, reason 'halted', success True The ADD never executed, the run reported a clean successful halt, and nothing in the output said otherwise. Since only eight opcodes are decoded, any real firmware is mostly undecodable - PUSH {lr}, which opens almost every compiled ARM function, is not among them. Loading a genuine EoS image would have produced a confident "PASSED" for a program that computed nothing. Now: - _execute() reports whether it decoded the instruction. - step() halts on an unknown opcode when strict_undefined is set (the default), recording the count and the offending PC/opcode. - run() reports reason 'undefined-instruction', success False, and prints the opcode, address, and the fact that this engine cannot execute a full firmware image - so the limitation is visible at the point it bites. - undefined_count is returned in the result. - strict_undefined can be turned off for tracing experiments. Undecoded instructions are still counted then, just not fatal. This bounds what the native engine honestly claims: it runs small hand-assembled programs, not an operating system. Extending the ISA is the work that would change that, and it is now a visible failure rather than a silent one. Verified: 1663 passed, 3 skipped (pre-existing: tkinter absent), 0 failed. The 1645 tests that predate this branch all still pass. ruff clean on the test file; the 4 errors in cpu/__init__.py are pre-existing, confirmed by stashing. Wheel rebuilt and reinstalled clean: 149 platforms, firmware run PASSED. Co-Authored-By: Claude Opus 5 (1M context) --- eosim/engine/native/__init__.py | 19 ++++++++- eosim/engine/native/cpu/__init__.py | 29 ++++++++++++- tests/unit/test_native_engine_execution.py | 47 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/eosim/engine/native/__init__.py b/eosim/engine/native/__init__.py index 8b5c877..57d678a 100644 --- a/eosim/engine/native/__init__.py +++ b/eosim/engine/native/__init__.py @@ -130,10 +130,16 @@ def run(self, max_cycles: int = 100000, timeout_s: float = 30.0) -> dict: # is counted before the break. Previously the halting instruction # was executed but not counted, leaving run()['cycles'] one behind # cpu.state.cycles for every program that halts. + undef_before = self.cpu.undefined_count stepped = self.cpu.step() executed += 1 if not stepped: - reason = 'halted' + # Distinguish a program that chose to stop from one the decoder + # could not follow. Both leave the core halted. + if self.cpu.undefined_count > undef_before: + reason = 'undefined-instruction' + else: + reason = 'halted' break if executed % 100 == 0: @@ -146,9 +152,17 @@ def run(self, max_cycles: int = 100000, timeout_s: float = 30.0) -> dict: elapsed = time.time() - self.start_time # A clean halt (UDF/breakpoint) is the only outcome the firmware chose. - # Exhausting the cycle budget or the clock means we stopped it. + # Exhausting the cycle budget or the clock means we stopped it, and an + # undefined opcode means the decoder could not follow the program. success = reason == 'halted' + if reason == 'undefined-instruction' and self.cpu.last_undefined: + pc, instr = self.cpu.last_undefined + self._uart_print( + '\nUndefined instruction 0x%08X at 0x%08X.\n' + 'This engine decodes a small ARM32 subset; it cannot execute a ' + 'full firmware image.\n' % (instr, pc)) + self._uart_print('\nSimulation stopped (%s): %d cycles in %.3fs\n' % (reason, executed, elapsed)) @@ -157,6 +171,7 @@ def run(self, max_cycles: int = 100000, timeout_s: float = 30.0) -> dict: 'reason': reason, 'cycles': executed, 'duration_s': elapsed, + 'undefined_count': self.cpu.undefined_count, 'boot_log': self.get_uart_output(), 'cpu_state': self.cpu.state.dump(), } diff --git a/eosim/engine/native/cpu/__init__.py b/eosim/engine/native/cpu/__init__.py index 2b42c55..33ae933 100644 --- a/eosim/engine/native/cpu/__init__.py +++ b/eosim/engine/native/cpu/__init__.py @@ -46,10 +46,20 @@ def __init__(self, arch: str = 'arm64'): self.max_instructions: int = 1000000 self.on_syscall: Optional[Callable] = None self.on_halt: Optional[Callable] = None + # An opcode this decoder does not implement used to fall through the + # if/elif chain and be treated as a no-op, so a real firmware image + # would "run" while computing nothing and could still reach a halt and + # report success. Only a handful of ARM instructions are decoded, so + # that is the common case, not an edge case. Refuse instead. + self.strict_undefined: bool = True + self.undefined_count: int = 0 + self.last_undefined: Optional[tuple] = None def reset(self, entry: int = 0, stack: int = 0x20000000): self.state.reset(entry, stack) self.trace_log.clear() + self.undefined_count = 0 + self.last_undefined = None def step(self) -> bool: if self.state.halted: @@ -59,7 +69,14 @@ def step(self) -> bool: return False if self.memory: instr = self.memory.read32(self.state.pc) - self._execute(instr) + decoded = self._execute(instr) + if not decoded and self.strict_undefined: + # Halt rather than skip: silently ignoring the opcode makes the + # run look successful while the program never actually ran. + self.state.halted = True + self.state.pc += 4 + self.state.cycles += 1 + return False self.state.pc += 4 self.state.cycles += 1 if self.state.cycles >= self.max_instructions: @@ -75,7 +92,8 @@ def run(self, max_cycles: int = 0) -> int: executed += 1 return executed - def _execute(self, instr: int): + def _execute(self, instr: int) -> bool: + """Execute one instruction. Returns False if the opcode is unknown.""" # Instruction decoder — handles common patterns if instr == 0: # NOP or uninitialized pass @@ -107,5 +125,12 @@ def _execute(self, instr: int): addr = self.state.pc + 8 + (instr & 0xFFF) if self.memory: self.memory.write32(addr, self.state.regs[rd]) + else: + # Not decoded. Record it and tell step(). + self.undefined_count += 1 + self.last_undefined = (self.state.pc, instr) + self.trace_log.append((self.state.pc, instr, self.state.cycles)) + return False # More instructions can be added for each architecture self.trace_log.append((self.state.pc, instr, self.state.cycles)) + return True diff --git a/tests/unit/test_native_engine_execution.py b/tests/unit/test_native_engine_execution.py index 73ee68d..b684b1a 100644 --- a/tests/unit/test_native_engine_execution.py +++ b/tests/unit/test_native_engine_execution.py @@ -132,6 +132,53 @@ def test_dump_state_has_real_newlines(self): assert "\\n" not in vm.dump_state() +class TestUndefinedInstructions: + """The decoder covers a small ARM32 subset. + + An opcode outside it used to fall through the if/elif chain and be treated + as a no-op. A real firmware image would therefore "run", compute nothing, + and could still reach a halt and be reported as a successful boot. Since + only a handful of instructions are decoded, that is the common case. + """ + + ADD_R0_R1_R2 = 0xE0810002 # not decoded + PUSH_LR = 0xE52DE004 # not decoded + + def test_undefined_opcode_stops_the_run(self): + vm = _vm(arm(mov_imm(1, 5), self.ADD_R0_R1_R2, UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert r["reason"] == "undefined-instruction" + assert r["success"] is False + assert r["undefined_count"] == 1 + + def test_undefined_opcode_is_not_silently_skipped(self): + """ADD r0,r1,r2 with r1=5, r2=3 must not leave r0 untouched and continue.""" + vm = _vm(arm(mov_imm(1, 5), mov_imm(2, 3), self.ADD_R0_R1_R2, UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert vm.cpu.state.regs[0] == 0 # it did not execute + assert r["success"] is False # and that is reported + assert vm.cpu.last_undefined[1] == self.ADD_R0_R1_R2 + + def test_prologue_of_a_real_function_is_rejected(self): + """PUSH {lr} opens almost every compiled ARM function.""" + vm = _vm(arm(self.PUSH_LR, UDF)) + r = vm.run(max_cycles=64, timeout_s=5) + + assert r["reason"] == "undefined-instruction" + assert "cannot execute a full firmware image" in r["boot_log"] + + def test_strict_mode_can_be_disabled(self): + """Opt out for tracing experiments, but never by default.""" + vm = _vm(arm(mov_imm(1, 5), self.ADD_R0_R1_R2, UDF)) + vm.cpu.strict_undefined = False + r = vm.run(max_cycles=64, timeout_s=5) + + assert r["reason"] == "halted" + assert r["undefined_count"] == 1 # still counted, just not fatal + + class TestTimeout: def test_timeout_is_reported_as_such(self): vm = _vm(arm(*([NOP] * 4))) From f507f9e561d644f238a1ad36f035021b2992b57f Mon Sep 17 00:00:00 2001 From: srpatcha Date: Fri, 28 Aug 2026 11:23:39 -0700 Subject: [PATCH 3/9] feat(ecosystem): test all 19 repos, and stop reporting passes that never ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eosim ecosystem` is the command for validating the whole organisation. It was reaching 2 of the 19 repos in the workspace, and could not report a failure for the ones it did reach. Discovery. find_repos held a hardcoded list of seven lowercase names — "eai", "eni", "eipc", "eboot", "ebuild-tool". None of those match a real directory on a case-sensitive filesystem, so only eos and eApps were found, and run_ecosystem_tests silently `continue`d past anything not on the list. Repos are now discovered by looking for .git, and their build system by the files they contain, so a new product becomes testable by being cloned. Fabricated results. test_c_repo ended with tests_passed = max(tests_passed, tests_run if build_ok else 0) tests_failed = max(0, tests_run - tests_passed) passed = build_ok and tests_failed == 0 where tests_run counted executables on disk. Any repo that compiled reported every test passing and a verdict of PASS, whether or not one test had run. ctest and pytest output is now parsed for the counts they actually printed, and `passed` derives from a single status field. A repo that cannot be tested reports SKIP, never PASS — an absent toolchain, a repo with no tests registered, and a green suite are three different facts. DEPS is a narrower case: the suite exists but the repo's own declared dependencies are missing here, which is not the repo's fault and not a FAIL. Repos with more than one build system now have each one exercised. ebuild is a Python CLI that also ships a CMakeLists integrating the sibling repos; running only the primary kind left one of the two untested, which is how a broken CMake configure sat behind a green Python suite. Two runner bugs found by running it: - pytest was invoked with -q. A repo whose own addopts already sets -q ended up at -q -q, which suppresses the summary line — a fully green EoStudio read as "no summary produced". The flag is no longer passed. - PYTHONPATH did not include the repo, so a repo that is not pip-installed failed at conftest import. Both the root and src/ are now offered, which covers the src-layout eDB uses. CLI: --simulate was accepted and then never passed to run_ecosystem_tests, so --no-simulate did nothing. Added --only to test named repos and --list to show what would run. before 2 repos reached, counts fabricated, verdict always ALL PASSED after 19 discovered, 7 passed, 1 failed, 11 skipped, 2420 tests run The one failure is real: ebuild's CMake configure, tracked in eBoot#60. Verification: 1702 pass (1666 before, +36 new tests here); full ecosystem run against the 19-repo workspace reproduces the table above. Co-Authored-By: Claude Opus 5 (1M context) --- eosim/cli/main.py | 28 +- eosim/integrations/ecosystem.py | 584 +++++++++++++++++++--------- tests/unit/test_ecosystem_runner.py | 221 +++++++++++ 3 files changed, 635 insertions(+), 198 deletions(-) create mode 100644 tests/unit/test_ecosystem_runner.py diff --git a/eosim/cli/main.py b/eosim/cli/main.py index 3898509..fdf9dd0 100644 --- a/eosim/cli/main.py +++ b/eosim/cli/main.py @@ -639,18 +639,38 @@ def eos_test_suite(source): @cli.command("ecosystem") @click.option("--workspace", default=None, help="EoS workspace root") @click.option("--simulate/--no-simulate", default=True, help="Run simulations") -def ecosystem(workspace, simulate): +@click.option("--only", multiple=True, + help="Test only these repos (repeatable, e.g. --only eos --only ebuild)") +@click.option("--list", "list_only", is_flag=True, + help="List the repos that would be tested, and how, then exit") +def ecosystem(workspace, simulate, only, list_only): """Test ALL EoS repos — build, test, simulate, validate.""" - from eosim.integrations.ecosystem import find_repos, run_ecosystem_tests + from eosim.integrations.ecosystem import ( + detect_kind, find_repos, run_ecosystem_tests) click.echo("EoSim Ecosystem Validation") click.echo("") repos = find_repos(workspace) if not repos: click.echo("No EoS repos found. Set --workspace or EOS_WORKSPACE env var.", err=True) sys.exit(1) - click.echo("Found %d repos: %s" % (len(repos), ", ".join(repos.keys()))) + + if only: + unknown = sorted(set(only) - set(repos)) + if unknown: + click.echo("Not in the workspace: %s" % ", ".join(unknown), err=True) + click.echo("Available: %s" % ", ".join(sorted(repos)), err=True) + sys.exit(1) + repos = {k: v for k, v in repos.items() if k in only} + + click.echo("Found %d repo(s):" % len(repos)) + for name in sorted(repos): + click.echo(" %-28s %s" % (name, detect_kind(repos[name]))) click.echo("") - report = run_ecosystem_tests(workspace) + if list_only: + return + + report = run_ecosystem_tests(workspace, simulate=simulate, + only=list(only) or None) click.echo(report.summary()) if report.repos_failed > 0: sys.exit(1) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index b003d2d..3d3afd9 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -1,18 +1,43 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2026 EoS Project -"""EoS Ecosystem Runner — test all repos through EoSim.""" +"""EoS Ecosystem Runner — build and test every EoS repo through EoSim. + +Discovery is by inspection, not by a hardcoded list: any immediate +subdirectory of the workspace holding a ``.git`` is a repo, and its build +system is detected from the files it actually contains. A new product becomes +testable by being cloned into the workspace, with no change here. + +Nothing in this module infers a pass. A repo that could not be tested reports +SKIP with the reason, never PASS -- an unrunnable suite and a green suite are +different facts and the report keeps them apart. +""" import os +import re import shutil import subprocess import sys import time from dataclasses import dataclass, field +#: Result statuses. SKIP means "not tested here", which is not a pass. +#: DEPS is a narrower SKIP: the suite exists and would run, but the repo's +#: own declared dependencies are absent from this environment. Keeping it +#: apart from FAIL matters -- a missing fastapi is not a broken test. +PASS, FAIL, SKIP, ERROR, DEPS = "PASS", "FAIL", "SKIP", "ERROR", "DEPS" + +#: Directories that live beside the repos but are not products. +_NOT_A_PRODUCT = {".github", ".git"} + +_BUILD_TIMEOUT_S = 900 +_TEST_TIMEOUT_S = 900 + @dataclass class RepoTestResult: repo: str - passed: bool = False + kind: str = "unknown" + status: str = SKIP + reason: str = "" tests_run: int = 0 tests_passed: int = 0 tests_failed: int = 0 @@ -21,287 +46,458 @@ class RepoTestResult: output: str = "" sim_result: dict = field(default_factory=dict) + @property + def passed(self) -> bool: + """True only when a suite actually ran and reported no failures.""" + return self.status == PASS + @dataclass class EcosystemReport: repos_tested: int = 0 repos_passed: int = 0 repos_failed: int = 0 + repos_skipped: int = 0 total_tests: int = 0 total_passed: int = 0 total_failed: int = 0 duration_s: float = 0.0 - results: list[RepoTestResult] = field(default_factory=list) - simulations: list[dict] = field(default_factory=list) + results: list = field(default_factory=list) + simulations: list = field(default_factory=list) def summary(self) -> str: lines = [] - lines.append("=" * 60) + lines.append("=" * 72) lines.append(" EoSim Ecosystem Validation Report") - lines.append("=" * 60) + lines.append("=" * 72) lines.append("") - lines.append(" Repos: %d tested | %d passed | %d failed" % ( - self.repos_tested, self.repos_passed, self.repos_failed)) - lines.append(" Tests: %d total | %d passed | %d failed" % ( + lines.append(" Repos: %d discovered | %d passed | %d failed | %d skipped" % ( + self.repos_tested, self.repos_passed, + self.repos_failed, self.repos_skipped)) + lines.append(" Tests: %d run | %d passed | %d failed" % ( self.total_tests, self.total_passed, self.total_failed)) - lines.append(f" Time: {self.duration_s:.2f}s") + lines.append(f" Time: {self.duration_s:.1f}s") lines.append("") - for r in self.results: - status = "PASS" if r.passed else "FAIL" - lines.append(" [%s] %-15s build:%-4s tests:%d/%d (%.1fs)" % ( - status, r.repo, "OK" if r.build_ok else "FAIL", - r.tests_passed, r.tests_run, r.duration_s)) + for r in sorted(self.results, key=lambda x: (x.status != FAIL, x.repo)): + detail = "" + if r.status in (PASS, FAIL, DEPS): + detail = "tests:%d/%d" % (r.tests_passed, r.tests_run) + if r.reason: + detail += " " + r.reason + elif r.reason: + detail = r.reason[:44] + lines.append(" [%-5s] %-26s %-9s %-46s (%.1fs)" % ( + r.status, r.repo, r.kind, detail, r.duration_s)) if self.simulations: lines.append("") lines.append(" Simulations:") for s in self.simulations: - lines.append(" [%s] %-20s %d cycles %.1fs" % ( - "PASS" if s.get("success") else "FAIL", + lines.append(" [%-5s] %-20s %d cycles %.1fs" % ( + PASS if s.get("success") else FAIL, s.get("platform", "?"), s.get("cycles", 0), s.get("duration_s", 0))) lines.append("") - lines.append("=" * 60) - all_pass = self.repos_failed == 0 - lines.append( - " VERDICT: %s" % - ("ALL PASSED" if all_pass else "FAILURES DETECTED")) - lines.append("=" * 60) + lines.append("=" * 72) + if self.repos_failed: + verdict = "FAILURES DETECTED" + elif self.repos_skipped and not self.repos_passed: + verdict = "NOTHING WAS TESTED" + elif self.repos_skipped: + verdict = "PASSED (%d repo(s) skipped — see above)" % self.repos_skipped + else: + verdict = "ALL PASSED" + lines.append(" VERDICT: %s" % verdict) + lines.append("=" * 72) return "\n".join(lines) -def find_repos(workspace: str = None) -> dict[str, str]: - if not workspace: - workspace = os.environ.get("EOS_WORKSPACE", "") - if not workspace: - for candidate in [ - os.path.join(os.getcwd(), ".."), - os.path.expanduser("~/EoS"), - "C:/Users/spatchava/EoS", - "/mnt/c/Users/spatchava/EoS", - ]: - if os.path.isdir(candidate) and os.path.isdir( - os.path.join(candidate, "eos")): - workspace = candidate - break +def find_repos(workspace: str = None) -> dict: + """Every git repo directly under the workspace, keyed by directory name. + + Discovery is by inspection rather than a fixed list, because a hardcoded + list goes stale and its casing has to match the filesystem exactly -- the + previous version looked for "eai"/"eni"/"eipc"/"eboot" and found none of + them on a case-sensitive filesystem. + """ + workspace = _resolve_workspace(workspace) if not workspace: return {} + repos = {} - for name in ["eos", "eboot", "eai", "eni", "eipc", "eApps", "ebuild-tool"]: + try: + entries = sorted(os.listdir(workspace)) + except OSError: + return {} + + for name in entries: + if name in _NOT_A_PRODUCT: + continue path = os.path.join(workspace, name) - if os.path.isdir(path): + if os.path.isdir(os.path.join(path, ".git")): repos[name] = path return repos +def _resolve_workspace(workspace: str = None) -> str: + if workspace: + return workspace if os.path.isdir(workspace) else "" + env = os.environ.get("EOS_WORKSPACE", "") + if env and os.path.isdir(env): + return env + for candidate in (os.getcwd(), os.path.join(os.getcwd(), "..")): + candidate = os.path.abspath(candidate) + if os.path.isdir(os.path.join(candidate, "eos", ".git")): + return candidate + return "" + + +def detect_kinds(path: str) -> list: + """Every build system the repo has, most significant first. + + A repo can carry more than one. ebuild is a Python CLI that also ships a + CMakeLists integrating sibling repos; returning only one of those would + leave the other untested, which is how a broken CMake build stayed + invisible while the Python suite was green. + """ + has = lambda *n: any(os.path.exists(os.path.join(path, x)) for x in n) + kinds = [] + if has("CMakeLists.txt"): + kinds.append("cmake") + if has("pyproject.toml", "setup.py"): + kinds.append("python") + if has("go.mod"): + kinds.append("go") + if has("Cargo.toml"): + kinds.append("cargo") + if has("package.json"): + kinds.append("node") + if not kinds and has("Makefile", "makefile"): + kinds.append("make") + if not kinds and has("mkdocs.yml", "_config.yml", "index.html"): + kinds.append("docs") + return kinds or ["unknown"] + + +def detect_kind(path: str) -> str: + """The repo's primary build system. See detect_kinds for the full set.""" + return detect_kinds(path)[0] + + +def _skip(result: RepoTestResult, reason: str, start: float) -> RepoTestResult: + result.status = SKIP + result.reason = reason + result.duration_s = time.time() - start + return result + + def test_c_repo(name: str, path: str) -> RepoTestResult: - result = RepoTestResult(repo=name) + """Configure, build and ctest a CMake repo, reporting ctest's own count.""" + result = RepoTestResult(repo=name, kind="cmake") start = time.time() + cmake = shutil.which("cmake") if not cmake: - result.output = "cmake not found" - result.duration_s = time.time() - start - return result + return _skip(result, "cmake not installed", start) + build_dir = os.path.join(path, "eosim-build") - os.makedirs(build_dir, exist_ok=True) - # Configure - cfg = [ - cmake, - "-B", - build_dir, - "-S", - path, - "-DEOS_BUILD_TESTS=ON", - "-DEBLDR_BUILD_TESTS=ON", - "-DEAI_BUILD_TESTS=ON", - "-DENI_BUILD_TESTS=ON"] + cfg = [cmake, "-S", path, "-B", build_dir, + "-DEOS_BUILD_TESTS=ON", "-DEBLDR_BUILD_TESTS=ON", + "-DEAI_BUILD_TESTS=ON", "-DENI_BUILD_TESTS=ON"] try: - r = subprocess.run(cfg, capture_output=True, text=True, timeout=120) - if r.returncode != 0: - result.output = r.stderr[-300:] - result.duration_s = time.time() - start - return result - except Exception as e: - result.output = str(e) + r = subprocess.run(cfg, capture_output=True, text=True, + timeout=_BUILD_TIMEOUT_S) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "configure: %s" % e + result.duration_s = time.time() - start + return result + if r.returncode != 0: + result.status, result.reason = FAIL, "cmake configure failed" + result.output = (r.stderr or r.stdout)[-2000:] result.duration_s = time.time() - start return result - # Build + try: - r = subprocess.run([cmake, "--build", build_dir], - capture_output=True, text=True, timeout=300) - result.build_ok = r.returncode == 0 - if not result.build_ok: - result.output = r.stderr[-300:] - result.duration_s = time.time() - start - return result - except Exception as e: - result.output = str(e) + r = subprocess.run([cmake, "--build", build_dir, "-j", + str(os.cpu_count() or 1)], + capture_output=True, text=True, + timeout=_BUILD_TIMEOUT_S) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "build: %s" % e + result.duration_s = time.time() - start + return result + if r.returncode != 0: + result.status, result.reason = FAIL, "build failed" + result.output = (r.stderr or r.stdout)[-2000:] result.duration_s = time.time() - start return result result.build_ok = True - # Run tests via ctest + + ctest = shutil.which("ctest") + if not ctest: + return _skip(result, "built OK; ctest not installed", start) + try: - r = subprocess.run([cmake, - "--build", - build_dir, - "--target", - "test"], - capture_output=True, - text=True, - timeout=120, - cwd=build_dir) - result.output = r.stdout - # Count test results - for line in r.stdout.split("\n"): - if "tests passed" in line.lower() or "test passed" in line.lower(): - import re - m = re.search(r"(\d+)\s+test", line) - if m: - result.tests_passed = int(m.group(1)) - except Exception: - pass - # Count test executables - for f in os.listdir(build_dir): - if f.startswith("test_"): - result.tests_run += 1 - if result.tests_run == 0: - test_dir = os.path.join(build_dir, "tests") - if os.path.isdir(test_dir): - for f in os.listdir(test_dir): - if f.startswith("test_"): - result.tests_run += 1 - result.tests_passed = max( - result.tests_passed, - result.tests_run if result.build_ok else 0) - result.tests_failed = max(0, result.tests_run - result.tests_passed) - result.passed = result.build_ok and result.tests_failed == 0 + r = subprocess.run([ctest, "--output-on-failure", "-j", + str(os.cpu_count() or 1)], + capture_output=True, text=True, + timeout=_TEST_TIMEOUT_S, cwd=build_dir) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "ctest: %s" % e + result.duration_s = time.time() - start + return result + + result.output = r.stdout[-4000:] + counted = _parse_ctest(r.stdout) + if counted is None: + # No summary line means ctest found no tests registered. That is not + # a pass; the repo built but nothing was verified. + return _skip(result, "built OK; no tests registered", start) + + result.tests_passed, result.tests_failed, result.tests_run = counted + result.status = PASS if (r.returncode == 0 and result.tests_failed == 0) else FAIL result.duration_s = time.time() - start return result +def _parse_ctest(out: str): + """(passed, failed, total) from ctest's summary line, or None.""" + m = re.search(r"(\d+)% tests passed,\s+(\d+) tests? failed out of (\d+)", out) + if not m: + return None + failed, total = int(m.group(2)), int(m.group(3)) + return total - failed, failed, total + + def test_python_repo(name: str, path: str) -> RepoTestResult: - result = RepoTestResult(repo=name) + """Run pytest and report the counts pytest itself printed.""" + result = RepoTestResult(repo=name, kind="python") start = time.time() - test_dir = os.path.join(path, "tests") - if not os.path.isdir(test_dir): - result.output = "no tests/ directory" - result.passed = True - result.build_ok = True + + if not any(os.path.isdir(os.path.join(path, d)) for d in ("tests", "test")): + return _skip(result, "no tests/ directory", start) + + # -q is deliberately not passed. A repo whose own addopts already sets it + # would end up at -q -q, which suppresses the summary line entirely and + # made a fully green EoStudio run look like "no summary produced". + # + # PYTHONPATH carries the repo root so `import ` resolves against the + # checkout under test rather than requiring it to be pip-installed first. + # A src/ layout puts the package under src/, not at the repo root, so + # both are offered; whichever is not a package directory is simply inert. + roots = [path] + src = os.path.join(path, "src") + if os.path.isdir(src): + roots.insert(0, src) + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + roots + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])) + try: + r = subprocess.run([sys.executable, "-m", "pytest", "--tb=line"], + capture_output=True, text=True, + timeout=_TEST_TIMEOUT_S, cwd=path, env=env) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "pytest: %s" % e result.duration_s = time.time() - start return result + result.build_ok = True - try: - r = subprocess.run([sys.executable, - "-m", - "pytest", - test_dir, - "-q", - "--tb=line"], - capture_output=True, - text=True, - timeout=120, - cwd=path) - result.output = r.stdout + r.stderr - import re - m = re.search(r"(\d+) passed", result.output) - if m: - result.tests_passed = int(m.group(1)) - m = re.search(r"(\d+) failed", result.output) - if m: - result.tests_failed = int(m.group(1)) - result.tests_run = result.tests_passed + result.tests_failed - result.passed = result.tests_failed == 0 - except Exception as e: - result.output = str(e) + result.output = (r.stdout + r.stderr)[-4000:] + counted = _parse_pytest(result.output) + + if counted is None: + # Exit 5 is pytest's "no tests collected"; anything else with no + # summary line is a collection error, which must not read as a pass. + reason = ("no tests collected" if r.returncode == 5 + else "pytest produced no summary (exit %d)" % r.returncode) + if r.returncode in (0, 5): + return _skip(result, reason, start) + result.status, result.reason = FAIL, reason + result.duration_s = time.time() - start + return result + + passed, failed, errors = counted + result.tests_passed = passed + result.tests_failed = failed + errors + result.tests_run = passed + failed + errors + + missing = _missing_modules(result.output) + if r.returncode == 0 and result.tests_failed == 0: + result.status = PASS + elif failed == 0 and errors and missing: + # Nothing asserted wrongly; the suite could not be imported because + # the repo's own dependencies are absent from this environment. + result.status = DEPS + result.reason = "needs %s" % ", ".join(missing) + else: + result.status = FAIL + if missing: + result.reason = "also missing %s" % ", ".join(missing) result.duration_s = time.time() - start return result +def _missing_modules(out: str) -> list: + """Module names pytest could not import, deduplicated and ordered.""" + seen = [] + for name in re.findall(r"No module named '([^']+)'", out): + top = name.split(".")[0] + if top not in seen: + seen.append(top) + return seen + + +def _parse_pytest(out: str): + """(passed, failed, errors) from pytest's summary line, or None. + + failed and errors are kept apart: a failed test is a broken assertion, + while an error is usually a collection problem -- most often an import + of a dependency that is declared but not installed here. + """ + if not re.search(r"\d+ (passed|failed|error)", out): + return None + def n(word): + m = re.search(r"(\d+) %s" % word, out) + return int(m.group(1)) if m else 0 + return n("passed"), n("failed"), n("error") + + def test_go_repo(name: str, path: str) -> RepoTestResult: - result = RepoTestResult(repo=name) + result = RepoTestResult(repo=name, kind="go") start = time.time() + go = shutil.which("go") if not go: - result.output = "go not found" - result.build_ok = True - result.passed = True + return _skip(result, "go not installed", start) + + try: + r = subprocess.run([go, "test", "-v", "-count=1", "./..."], + capture_output=True, text=True, + timeout=_TEST_TIMEOUT_S, cwd=path) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "go test: %s" % e result.duration_s = time.time() - start return result + result.build_ok = True + result.output = r.stdout[-4000:] + result.tests_passed = r.stdout.count("--- PASS") + result.tests_failed = r.stdout.count("--- FAIL") + result.tests_run = result.tests_passed + result.tests_failed + if result.tests_run == 0: + return _skip(result, "no Go tests found", start) + result.status = PASS if (r.returncode == 0 and result.tests_failed == 0) else FAIL + result.duration_s = time.time() - start + return result + + +def test_node_repo(name: str, path: str) -> RepoTestResult: + result = RepoTestResult(repo=name, kind="node") + start = time.time() + + npm = shutil.which("npm") + if not npm: + return _skip(result, "npm not installed", start) + if not os.path.isdir(os.path.join(path, "node_modules")): + return _skip(result, "dependencies not installed (npm ci)", start) + try: - r = subprocess.run([go, - "test", - "-v", - "-count=1", - "./..."], - capture_output=True, - text=True, - timeout=120, - cwd=path) - result.output = r.stdout - result.tests_run = result.output.count( - "--- PASS") + result.output.count("--- FAIL") - result.tests_passed = result.output.count("--- PASS") - result.tests_failed = result.output.count("--- FAIL") - result.passed = result.tests_failed == 0 - except Exception as e: - result.output = str(e) + r = subprocess.run([npm, "test", "--silent"], + capture_output=True, text=True, + timeout=_TEST_TIMEOUT_S, cwd=path) + except (subprocess.TimeoutExpired, OSError) as e: + result.status, result.reason = ERROR, "npm test: %s" % e + result.duration_s = time.time() - start + return result + + result.build_ok = True + result.output = (r.stdout + r.stderr)[-4000:] + result.status = PASS if r.returncode == 0 else FAIL result.duration_s = time.time() - start return result +#: Detected kind -> the runner that knows how to test it. +_RUNNERS = { + "cmake": test_c_repo, + "python": test_python_repo, + "go": test_go_repo, + "node": test_node_repo, +} + + +def test_repo(name: str, path: str) -> RepoTestResult: + """Test one repo with the runner for its primary build system.""" + return test_repo_all(name, path)[0] + + +def test_repo_all(name: str, path: str) -> list: + """One result per build system the repo has. + + Every detected system is exercised, so a repo cannot hide a broken native + build behind a green Python suite. + """ + results = [] + for kind in detect_kinds(path): + runner = _RUNNERS.get(kind) + if runner is None: + r = RepoTestResult(repo=name, kind=kind) + results.append( + _skip(r, "no runner for a '%s' project" % kind, time.time())) + else: + results.append(runner(name, path)) + return results + + def run_simulations(platforms: list = None) -> list: if not platforms: - platforms = [ - "stm32f4", - "raspi4", - "arm64-linux", - "riscv64-linux", - "x86_64-linux"] + platforms = ["stm32f4", "raspi4", "arm64-linux", + "riscv64-linux", "x86_64-linux"] results = [] from eosim.engine.native import VirtualMachine for plat in platforms: - vm = VirtualMachine(plat, "arm64", ram_mb=32) - sim = vm.run(max_cycles=200, timeout_s=5) - results.append({ - "platform": plat, - "success": sim["success"], - "cycles": sim["cycles"], - "duration_s": sim["duration_s"], - }) + try: + vm = VirtualMachine(plat, "arm64", ram_mb=32) + sim = vm.run(max_cycles=200, timeout_s=5) + results.append({"platform": plat, "success": sim["success"], + "cycles": sim["cycles"], + "duration_s": sim["duration_s"]}) + except Exception as e: + results.append({"platform": plat, "success": False, + "cycles": 0, "duration_s": 0.0, "error": str(e)}) return results -def run_ecosystem_tests(workspace: str = None) -> EcosystemReport: +def run_ecosystem_tests(workspace: str = None, simulate: bool = True, + only: list = None) -> EcosystemReport: + """Build and test every repo in the workspace.""" report = EcosystemReport() start = time.time() - repos = find_repos(workspace) - if not repos: - return report - - for name, path in repos.items(): - if name in ["eos", "eboot", "eai", "eni"]: - r = test_c_repo(name, path) - elif name in ["eApps"]: - r = test_python_repo(name, path) - elif name in ["eipc"]: - r = test_go_repo(name, path) - elif name in ["ebuild-tool"]: - r = test_python_repo(name, path) - else: + + for name, path in find_repos(workspace).items(): + if only and name not in only: continue - report.results.append(r) + per_kind = test_repo_all(name, path) + report.results.extend(per_kind) report.repos_tested += 1 - if r.passed: + + # A repo counts as failed if any of its build systems failed, and as + # passed only if at least one actually ran and none failed. + statuses = {r.status for r in per_kind} + if statuses & {FAIL, ERROR}: + report.repos_failed += 1 + elif PASS in statuses: report.repos_passed += 1 else: - report.repos_failed += 1 - report.total_tests += r.tests_run - report.total_passed += r.tests_passed - report.total_failed += r.tests_failed + report.repos_skipped += 1 # SKIP and DEPS are both "not tested" + + for r in per_kind: + report.total_tests += r.tests_run + report.total_passed += r.tests_passed + report.total_failed += r.tests_failed - # Run simulations - report.simulations = run_simulations() + if simulate: + report.simulations = run_simulations() report.duration_s = time.time() - start return report diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py new file mode 100644 index 0000000..f9bfbff --- /dev/null +++ b/tests/unit/test_ecosystem_runner.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""The ecosystem runner must find every product and never invent a pass. + +Two defects motivate this file: + +1. find_repos held a hardcoded list of seven lowercase names + ("eai", "eni", "eipc", "eboot", "ebuild-tool"). None of those match the + real directory names on a case-sensitive filesystem, so it discovered + 2 of the 19 repos in the workspace and run_ecosystem_tests silently + `continue`d past anything not on the list. + +2. test_c_repo ended with + + tests_passed = max(tests_passed, tests_run if build_ok else 0) + tests_failed = max(0, tests_run - tests_passed) + passed = build_ok and tests_failed == 0 + + where tests_run was a count of executables on disk. Any repo that + compiled reported every test passing and a verdict of PASS, whether or + not a single test had run. +""" + +import os + +import pytest + +from eosim.integrations import ecosystem as eco +from eosim.integrations.ecosystem import ( + DEPS, ERROR, FAIL, PASS, SKIP, + EcosystemReport, RepoTestResult, + _parse_ctest, _parse_pytest, + detect_kind, detect_kinds, find_repos, + test_repo as run_one_repo, +) + + +def _repo(tmp_path, name, *files): + d = tmp_path / name + (d / ".git").mkdir(parents=True) + for f in files: + (d / f).write_text("", encoding="utf-8") + return d + + +class TestDiscovery: + def test_finds_every_git_directory(self, tmp_path): + for n in ("eos", "eAI", "eNI", "eIPC", "eBoot", "ebuild", "EoStudio"): + _repo(tmp_path, n) + assert set(find_repos(str(tmp_path))) == { + "eos", "eAI", "eNI", "eIPC", "eBoot", "ebuild", "EoStudio"} + + def test_casing_is_not_assumed(self, tmp_path): + """The old list looked for 'eai'; the directory is 'eAI'.""" + _repo(tmp_path, "eAI") + assert "eAI" in find_repos(str(tmp_path)) + + def test_non_repo_directories_are_ignored(self, tmp_path): + _repo(tmp_path, "eos") + (tmp_path / "scratch").mkdir() + (tmp_path / "docs").mkdir() + assert set(find_repos(str(tmp_path))) == {"eos"} + + def test_dot_github_is_not_a_product(self, tmp_path): + _repo(tmp_path, ".github") + _repo(tmp_path, "eos") + assert set(find_repos(str(tmp_path))) == {"eos"} + + def test_missing_workspace_is_empty_not_an_error(self): + assert find_repos("/nonexistent/path") == {} + + +class TestKindDetection: + @pytest.mark.parametrize("marker,kind", [ + ("CMakeLists.txt", "cmake"), + ("pyproject.toml", "python"), + ("setup.py", "python"), + ("go.mod", "go"), + ("Cargo.toml", "cargo"), + ("package.json", "node"), + ("Makefile", "make"), + ]) + def test_detects_from_files_present(self, tmp_path, marker, kind): + d = _repo(tmp_path, "r", marker) + assert detect_kind(str(d)) == kind + + def test_cmake_wins_over_a_wrapper_makefile(self, tmp_path): + """A CMake project often ships a convenience Makefile; ctest is the + runner that knows about its tests.""" + d = _repo(tmp_path, "r", "CMakeLists.txt", "Makefile") + assert detect_kind(str(d)) == "cmake" + + def test_unknown_when_nothing_is_recognised(self, tmp_path): + assert detect_kind(str(_repo(tmp_path, "r"))) == "unknown" + + +class TestNoFabricatedPasses: + """Nothing may report PASS unless a suite ran and reported no failures.""" + + def test_unknown_project_is_skipped_not_passed(self, tmp_path): + d = _repo(tmp_path, "mystery") + r = run_one_repo("mystery", str(d)) + assert r.status == SKIP + assert r.passed is False + + def test_python_repo_without_tests_is_skipped_not_passed(self, tmp_path): + d = _repo(tmp_path, "p", "pyproject.toml") + r = run_one_repo("p", str(d)) + assert r.status == SKIP + assert r.passed is False + assert "no tests" in r.reason + + def test_missing_toolchain_is_skipped_not_passed(self, tmp_path, monkeypatch): + monkeypatch.setattr(eco.shutil, "which", lambda _n: None) + d = _repo(tmp_path, "g", "go.mod") + r = run_one_repo("g", str(d)) + assert r.status == SKIP + assert r.passed is False + + def test_passed_is_derived_from_status_only(self): + """The old code could set build_ok and a pass count independently of + whether anything ran; `passed` now has one source of truth.""" + r = RepoTestResult(repo="x", build_ok=True, tests_run=99, tests_passed=99) + assert r.passed is False # status is still the default SKIP + r.status = PASS + assert r.passed is True + + +class TestResultParsing: + def test_ctest_summary_is_read_not_guessed(self): + out = "100% tests passed, 0 tests failed out of 24" + assert _parse_ctest(out) == (24, 0, 24) + + def test_ctest_failures_are_counted(self): + out = "91% tests passed, 2 tests failed out of 23" + assert _parse_ctest(out) == (21, 2, 23) + + def test_ctest_without_a_summary_is_not_a_pass(self): + assert _parse_ctest("ninja: no work to do") is None + + def test_pytest_counts_are_read(self): + assert _parse_pytest("288 passed in 2.21s") == (288, 0, 0) + + def test_failures_and_errors_are_kept_apart(self): + """A broken assertion and an uninstalled dependency are different + problems, and only one of them is the repo's fault.""" + assert _parse_pytest("3 failed, 256 passed, 1 error in 2.3s") == (256, 3, 1) + + def test_pytest_collection_error_is_counted(self): + """An uncollectable suite must never read as zero problems.""" + assert _parse_pytest( + "!!! Interrupted: 1 error during collection !!!") == (0, 0, 1) + + def test_pytest_silence_yields_no_counts(self): + """A crash with no summary at all is handled by the caller, which + turns it into SKIP or FAIL based on the exit code -- never a pass.""" + assert _parse_pytest("Segmentation fault") is None + + +class TestMultipleBuildSystems: + """A repo with two build systems must have both exercised: ebuild is a + Python CLI whose CMakeLists integrates sibling repos, and a broken CMake + build there stayed invisible behind a green Python suite.""" + + def test_both_are_detected(self, tmp_path): + d = _repo(tmp_path, "ebuild", "CMakeLists.txt", "pyproject.toml") + assert detect_kinds(str(d)) == ["cmake", "python"] + + def test_primary_kind_is_the_first(self, tmp_path): + d = _repo(tmp_path, "ebuild", "CMakeLists.txt", "pyproject.toml") + assert detect_kind(str(d)) == "cmake" + + def test_a_make_wrapper_is_not_a_second_system(self, tmp_path): + """Makefile is only a fallback; a CMake project's Makefile is a + convenience wrapper, not a separate suite to run.""" + d = _repo(tmp_path, "r", "CMakeLists.txt", "Makefile") + assert detect_kinds(str(d)) == ["cmake"] + + +class TestDependencyGapsAreNotFailures: + def test_missing_modules_are_extracted(self, tmp_path): + from eosim.integrations.ecosystem import _missing_modules + out = ("ModuleNotFoundError: No module named 'fastapi'\n" + "ModuleNotFoundError: No module named 'fastapi.routing'\n" + "ModuleNotFoundError: No module named 'httpx'\n") + assert _missing_modules(out) == ["fastapi", "httpx"] + + def test_deps_is_not_a_pass(self): + r = RepoTestResult(repo="eDB", status=DEPS, tests_passed=23) + assert r.passed is False + + def test_deps_repos_are_not_counted_as_passed(self): + rep = EcosystemReport(repos_tested=1, repos_skipped=1) + assert "NOTHING WAS TESTED" in rep.summary() + + +class TestReportVerdict: + def test_all_skipped_does_not_read_as_success(self): + rep = EcosystemReport(repos_tested=3, repos_skipped=3) + assert "NOTHING WAS TESTED" in rep.summary() + + def test_skips_are_surfaced_alongside_passes(self): + rep = EcosystemReport(repos_tested=3, repos_passed=2, repos_skipped=1) + assert "1 repo(s) skipped" in rep.summary() + + def test_any_failure_dominates(self): + rep = EcosystemReport(repos_tested=3, repos_passed=2, repos_failed=1) + assert "FAILURES DETECTED" in rep.summary() + + def test_clean_run_says_so(self): + rep = EcosystemReport(repos_tested=2, repos_passed=2) + assert "ALL PASSED" in rep.summary() + + def test_failures_are_listed_first(self): + rep = EcosystemReport(results=[ + RepoTestResult(repo="aaa", status=PASS), + RepoTestResult(repo="zzz", status=FAIL), + ]) + body = rep.summary() + assert body.index("zzz") < body.index("aaa") From e80c4022938390341262c3d3d86a4281743d8864 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Fri, 28 Aug 2026 11:28:43 -0700 Subject: [PATCH 4/9] fix(ecosystem): build outside the checkout, and keep blocked tests out of the failure count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the first full run exposed. The CMake tree was written to /eosim-build, so every C repo the runner touched was left with an untracked directory in it — a dirty working tree in eight repos, and in one without a matching .gitignore, something a developer could commit by accident. Build trees now go under ~/.cache/eosim/ecosystem, one per repo, overridable with EOSIM_BUILD_ROOT. The summary also read "0 repos failed" beside "16 tests failed", because a DEPS repo's uncollectable tests were folded into total_failed. Those tests never ran; counting them as failures reads as broken code when the cause is an absent dependency. They are counted and labelled separately now. Tests: 2463 run | 2447 passed | 0 failed | 16 blocked on missing deps Verification: 1706 pass; a full ecosystem run leaves no repo dirty. Co-Authored-By: Claude Opus 5 (1M context) --- eosim/integrations/ecosystem.py | 34 ++++++++++++++++++++++++---- tests/unit/test_ecosystem_runner.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index 3d3afd9..2c37dcf 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -61,6 +61,7 @@ class EcosystemReport: total_tests: int = 0 total_passed: int = 0 total_failed: int = 0 + total_blocked: int = 0 duration_s: float = 0.0 results: list = field(default_factory=list) simulations: list = field(default_factory=list) @@ -74,8 +75,13 @@ def summary(self) -> str: lines.append(" Repos: %d discovered | %d passed | %d failed | %d skipped" % ( self.repos_tested, self.repos_passed, self.repos_failed, self.repos_skipped)) - lines.append(" Tests: %d run | %d passed | %d failed" % ( - self.total_tests, self.total_passed, self.total_failed)) + counts = " Tests: %d run | %d passed | %d failed" % ( + self.total_tests, self.total_passed, self.total_failed) + if self.total_blocked: + # Blocked tests never ran; folding them into "failed" would read + # as broken code when the cause is an absent dependency. + counts += " | %d blocked on missing deps" % self.total_blocked + lines.append(counts) lines.append(f" Time: {self.duration_s:.1f}s") lines.append("") for r in sorted(self.results, key=lambda x: (x.status != FAIL, x.repo)): @@ -191,6 +197,23 @@ def _skip(result: RepoTestResult, reason: str, start: float) -> RepoTestResult: return result +def _build_dir_for(path: str) -> str: + """Where to put a repo's CMake tree. + + Deliberately outside the checkout: building into /eosim-build left + an untracked directory in every repo the runner touched, which shows up + as a dirty working tree and, in a repo without a matching .gitignore, as + something a developer might commit. EOSIM_BUILD_ROOT overrides it. + """ + root = os.environ.get("EOSIM_BUILD_ROOT") + if not root: + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + root = os.path.join(base, "eosim", "ecosystem") + build_dir = os.path.join(root, os.path.basename(os.path.abspath(path))) + os.makedirs(build_dir, exist_ok=True) + return build_dir + + def test_c_repo(name: str, path: str) -> RepoTestResult: """Configure, build and ctest a CMake repo, reporting ctest's own count.""" result = RepoTestResult(repo=name, kind="cmake") @@ -200,7 +223,7 @@ def test_c_repo(name: str, path: str) -> RepoTestResult: if not cmake: return _skip(result, "cmake not installed", start) - build_dir = os.path.join(path, "eosim-build") + build_dir = _build_dir_for(path) cfg = [cmake, "-S", path, "-B", build_dir, "-DEOS_BUILD_TESTS=ON", "-DEBLDR_BUILD_TESTS=ON", "-DEAI_BUILD_TESTS=ON", "-DENI_BUILD_TESTS=ON"] @@ -494,7 +517,10 @@ def run_ecosystem_tests(workspace: str = None, simulate: bool = True, for r in per_kind: report.total_tests += r.tests_run report.total_passed += r.tests_passed - report.total_failed += r.tests_failed + if r.status == DEPS: + report.total_blocked += r.tests_failed + else: + report.total_failed += r.tests_failed if simulate: report.simulations = run_simulations() diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py index f9bfbff..2d87844 100644 --- a/tests/unit/test_ecosystem_runner.py +++ b/tests/unit/test_ecosystem_runner.py @@ -219,3 +219,38 @@ def test_failures_are_listed_first(self): ]) body = rep.summary() assert body.index("zzz") < body.index("aaa") + + +class TestBuildDirIsOutsideTheCheckout: + """Building into the repo leaves an untracked directory behind in every + repo the runner touches.""" + + def test_build_dir_is_not_inside_the_repo(self, tmp_path, monkeypatch): + from eosim.integrations.ecosystem import _build_dir_for + monkeypatch.setenv("EOSIM_BUILD_ROOT", str(tmp_path / "cache")) + repo = tmp_path / "eos" + repo.mkdir() + build = _build_dir_for(str(repo)) + assert not build.startswith(str(repo)) + + def test_each_repo_gets_its_own_tree(self, tmp_path, monkeypatch): + from eosim.integrations.ecosystem import _build_dir_for + monkeypatch.setenv("EOSIM_BUILD_ROOT", str(tmp_path / "cache")) + (tmp_path / "eos").mkdir() + (tmp_path / "eBoot").mkdir() + a = _build_dir_for(str(tmp_path / "eos")) + b = _build_dir_for(str(tmp_path / "eBoot")) + assert a != b + + +class TestBlockedTestsAreNotFailures: + def test_blocked_count_is_reported_separately(self): + rep = EcosystemReport(repos_tested=1, total_tests=39, + total_passed=23, total_blocked=16) + body = rep.summary() + assert "16 blocked on missing deps" in body + assert "16 failed" not in body + + def test_blocked_is_omitted_when_zero(self): + rep = EcosystemReport(total_tests=10, total_passed=10) + assert "blocked" not in rep.summary() From 37fd2f1717f98bc4d3c1c2242dc787a30f10f4ba Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sat, 29 Aug 2026 04:38:07 -0700 Subject: [PATCH 5/9] feat(ecosystem): run Makefile-driven repos, and report make by its exit code eosllm is built and tested through a Makefile, so the runner reported "no runner for a 'make' project" and it contributed nothing to the ecosystem run. Its Makefile declares a `test` target; that target now runs. `make test` is only attempted when the Makefile actually declares the rule. Running it otherwise fails with "No rule to make target", which would read as a broken repo rather than one that keeps its tests somewhere else. There is no count to parse out of make, and inventing one would be exactly the fabrication this module was rewritten to remove, so a make repo reports its exit code instead of a tests:0/0 that looks like nothing ran. The report shows a count where there is one and the reason where there is not. Measured against the 19-repo workspace, with the toolchains now installed on this machine: before 8 passed | 1 failed | 10 skipped | 2867 tests after 9 passed | 1 failed | 9 skipped | 2867 tests eIPC also moved from SKIP to PASS with 152 Go tests once Go was available -- no change needed here, which is the point of detecting toolchains at run time rather than hardcoding what a machine has. 1710 tests pass, 44 in this module. Co-Authored-By: Claude Opus 5 (1M context) --- eosim/integrations/ecosystem.py | 54 ++++++++++++++++++++++++++++- tests/unit/test_ecosystem_runner.py | 37 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index 2c37dcf..a7475fb 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -86,10 +86,13 @@ def summary(self) -> str: lines.append("") for r in sorted(self.results, key=lambda x: (x.status != FAIL, x.repo)): detail = "" - if r.status in (PASS, FAIL, DEPS): + if r.status in (PASS, FAIL, DEPS) and r.tests_run: detail = "tests:%d/%d" % (r.tests_passed, r.tests_run) if r.reason: detail += " " + r.reason + elif r.reason: + # No count to show — a Makefile-driven suite, or a skip. + detail = r.reason[:44] elif r.reason: detail = r.reason[:44] lines.append(" [%-5s] %-26s %-9s %-46s (%.1fs)" % ( @@ -440,12 +443,61 @@ def test_node_repo(name: str, path: str) -> RepoTestResult: return result +def test_make_repo(name: str, path: str) -> RepoTestResult: + """Run a Makefile's own `test` target. + + Only when the Makefile actually declares one. Running `make test` against + a Makefile without that rule fails with "No rule to make target", which + would read as a broken repo rather than a repo that keeps its tests + somewhere else. + """ + result = RepoTestResult(repo=name, kind="make") + start = time.time() + + make = shutil.which("make") + if not make: + return _skip(result, "make not installed", start) + + makefile = next( + (os.path.join(path, n) for n in ("Makefile", "makefile", "GNUmakefile") + if os.path.isfile(os.path.join(path, n))), + None, + ) + if makefile is None: + return _skip(result, "no Makefile", start) + try: + with open(makefile, encoding="utf-8", errors="replace") as fh: + body = fh.read() + except OSError as exc: + return _skip(result, "cannot read the Makefile: %s" % exc, start) + if not re.search(r"^test\s*:", body, re.MULTILINE): + return _skip(result, "Makefile declares no 'test' target", start) + + try: + r = subprocess.run([make, "-C", path, "test"], capture_output=True, + text=True, timeout=_TEST_TIMEOUT_S) + except (subprocess.TimeoutExpired, OSError) as exc: + result.status, result.reason = ERROR, "make test: %s" % exc + result.duration_s = time.time() - start + return result + + result.build_ok = True + result.output = (r.stdout + r.stderr)[-4000:] + result.status = PASS if r.returncode == 0 else FAIL + # A Makefile reports through its exit code; there is no count to parse and + # inventing one would be the fabrication this module exists to avoid. + result.reason = "`make test` exit %d" % r.returncode + result.duration_s = time.time() - start + return result + + #: Detected kind -> the runner that knows how to test it. _RUNNERS = { "cmake": test_c_repo, "python": test_python_repo, "go": test_go_repo, "node": test_node_repo, + "make": test_make_repo, } diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py index 2d87844..64bb45c 100644 --- a/tests/unit/test_ecosystem_runner.py +++ b/tests/unit/test_ecosystem_runner.py @@ -254,3 +254,40 @@ def test_blocked_count_is_reported_separately(self): def test_blocked_is_omitted_when_zero(self): rep = EcosystemReport(total_tests=10, total_passed=10) assert "blocked" not in rep.summary() + + +class TestMakeRunner: + """eosllm is driven by a Makefile. Before this it reported + "no runner for a 'make' project" and contributed nothing.""" + + def test_make_is_wired_to_a_runner(self, tmp_path): + d = _repo(tmp_path, "m", "Makefile") + (d / "Makefile").write_text("test:\n\t@true\n", encoding="utf-8") + r = run_one_repo("m", str(d)) + assert r.kind == "make" + assert r.status == PASS + + def test_a_makefile_without_a_test_target_is_skipped(self, tmp_path): + """`make test` against a Makefile with no such rule fails with + "No rule to make target", which would read as a broken repo rather + than one that keeps its tests elsewhere.""" + d = _repo(tmp_path, "m", "Makefile") + (d / "Makefile").write_text("all:\n\t@true\n", encoding="utf-8") + r = run_one_repo("m", str(d)) + assert r.status == SKIP + assert "test" in r.reason + + def test_a_failing_make_test_is_a_failure(self, tmp_path): + d = _repo(tmp_path, "m", "Makefile") + (d / "Makefile").write_text("test:\n\t@exit 3\n", encoding="utf-8") + r = run_one_repo("m", str(d)) + assert r.status == FAIL + + def test_make_reports_its_exit_code_not_an_invented_count(self, tmp_path): + """There is no count to parse from a Makefile, and inventing one is + the fabrication this module exists to prevent.""" + d = _repo(tmp_path, "m", "Makefile") + (d / "Makefile").write_text("test:\n\t@true\n", encoding="utf-8") + r = run_one_repo("m", str(d)) + assert r.tests_run == 0 + assert "exit 0" in r.reason From 72df0d140998575febbd459c1e7986e98d3ca429 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 05:50:06 -0700 Subject: [PATCH 6/9] fix(ecosystem): detect build systems below the repository root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_kinds() inspected only the root, so three of the nineteen repos in the workspace reported "unknown", found no runner, and were skipped: eCAD-Hardware-Products tests/, no packaging file eos-aero AeroSwift/software/web_app/package.json eos-health firmware/build-system/CMakeLists.txt apps/web/package.json 16% of the organisation was invisible to the tool whose purpose is to test the organisation. eos-health is the sharpest case: it ships firmware with a CMake build and a web app, and neither was ever compiled or tested. The summary counted them as skipped, next to the passes: Repos: 19 discovered | N passed | 0 failed | 3 skipped "0 failed" with everything green is the shape a healthy run makes. A skip reads as a pass at a glance; the reason only appears in the detail rows. Same class as the fabricated pass removed in #16 — a repo that was never tested reporting as though nothing was wrong — through a different door. Two changes. detect_components() returns (kind, directory) pairs. The runners build from the directory handed to them, so a nested component has to carry its own location; returning "cmake" for eos-health without it would send cmake -S at a root with no CMakeLists.txt, replacing a silent skip with a spurious failure. Root detection runs first and is returned unchanged when it finds anything, so the scan only ever runs for a repo that would have been skipped. Bounded to four levels and blind to node_modules, build, dist, venv, vendor and friends — a vendored package.json belongs to a dependency, not to the repo. A tests/ directory holding Python now identifies a Python project. test_python_repo() only ever required tests/; demanding pyproject.toml to reach it was the detector asking for something the runner does not use. Guarded on no other kind having matched, so eBoot — CMakeLists.txt plus a tests/ directory full of C — does not also get pytest pointed at it. Verified no regression: detect_kinds output compared against the original for every repo in the workspace. One difference, the intended one: eCAD-Hardware-Products ['unknown'] -> ['python'] All other 18 identical, and detect_components equals the old detect_kinds result for all 17 root-detected repos. After: 0 unknown, 19 repos yielding 26 components. tests/unit/test_ecosystem_runner.py 44 -> 54 full suite 1710 -> 1720 passed, 0 failed Measured against this branch's base, which is #16, not master. master alone is at 1648; #16 carries it to 1710. This stacks on #16 and needs it first. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) --- eosim/integrations/ecosystem.py | 85 ++++++++++++++++++++++++- tests/unit/test_ecosystem_runner.py | 96 ++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index a7475fb..eeb6592 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -181,6 +181,17 @@ def detect_kinds(path: str) -> list: kinds.append("cargo") if has("package.json"): kinds.append("node") + if not kinds and _has_python_tests(path): + # A repo can be a perfectly good pytest project with no packaging file. + # eCAD-Hardware-Products carries tests/test_rtl_models.py and no + # pyproject.toml, and test_python_repo() only ever needed a tests/ + # directory. Requiring pyproject.toml to notice that was the detector + # asking for something the runner does not use. + # + # Guarded on `not kinds` so a repo whose real build system is already + # identified does not also get pytest pointed at it: eBoot has both a + # CMakeLists.txt and a tests/ directory full of C. + kinds.append("python") if not kinds and has("Makefile", "makefile"): kinds.append("make") if not kinds and has("mkdocs.yml", "_config.yml", "index.html"): @@ -188,6 +199,69 @@ def detect_kinds(path: str) -> list: return kinds or ["unknown"] +def _has_python_tests(path: str) -> bool: + """True when tests/ or test/ holds at least one Python file.""" + for d in ("tests", "test"): + tdir = os.path.join(path, d) + if not os.path.isdir(tdir): + continue + for _root, _dirs, files in os.walk(tdir): + if any(f.endswith(".py") for f in files): + return True + return False + + +# Directories that never contain a component of the repo itself. node_modules +# is the one that matters: a vendored package.json would otherwise be reported +# as a build system belonging to the repo. +_NOT_A_COMPONENT_DIR = { + ".git", "node_modules", "build", "dist", "venv", ".venv", "__pycache__", + ".tox", "target", "vendor", "third_party", ".mypy_cache", ".pytest_cache", +} + +_NESTED_MAX_DEPTH = 4 + + +def detect_components(path: str) -> list: + """(kind, directory) for every build system in the repo, root or nested. + + The runners build from the directory they are handed, so a nested component + has to carry its own location. Returning just the kind would send + test_c_repo at eos-health's root, which has no CMakeLists.txt, turning a + silent skip into a spurious failure. + + Root detection runs first and is returned unchanged when it finds anything, + so every repo that is detected today keeps taking exactly the path it takes + today. The scan below only ever runs for a repo that would otherwise have + been reported "unknown" and skipped. + """ + kinds = detect_kinds(path) + if kinds != ["unknown"]: + return [(k, path) for k in kinds] + + found = [] + seen = set() + base_depth = os.path.abspath(path).count(os.sep) + + for root, dirs, _files in os.walk(path): + dirs[:] = sorted(d for d in dirs + if d not in _NOT_A_COMPONENT_DIR and not d.startswith(".")) + if os.path.abspath(root).count(os.sep) - base_depth >= _NESTED_MAX_DEPTH: + dirs[:] = [] + continue + if root == path: + continue + for kind in detect_kinds(root): + if kind in ("unknown", "docs"): + continue + key = (kind, root) + if key not in seen: + seen.add(key) + found.append(key) + + return found or [("unknown", path)] + + def detect_kind(path: str) -> str: """The repo's primary build system. See detect_kinds for the full set.""" return detect_kinds(path)[0] @@ -513,14 +587,19 @@ def test_repo_all(name: str, path: str) -> list: build behind a green Python suite. """ results = [] - for kind in detect_kinds(path): + for kind, comp_path in detect_components(path): + # Label a nested component by its path within the repo, so one repo + # yielding several rows stays readable and a failure names the + # directory that produced it. + rel = os.path.relpath(comp_path, path) + label = name if rel == "." else "%s/%s" % (name, rel.replace(os.sep, "/")) runner = _RUNNERS.get(kind) if runner is None: - r = RepoTestResult(repo=name, kind=kind) + r = RepoTestResult(repo=label, kind=kind) results.append( _skip(r, "no runner for a '%s' project" % kind, time.time())) else: - results.append(runner(name, path)) + results.append(runner(label, comp_path)) return results diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py index 64bb45c..822d546 100644 --- a/tests/unit/test_ecosystem_runner.py +++ b/tests/unit/test_ecosystem_runner.py @@ -31,7 +31,7 @@ DEPS, ERROR, FAIL, PASS, SKIP, EcosystemReport, RepoTestResult, _parse_ctest, _parse_pytest, - detect_kind, detect_kinds, find_repos, + detect_components, detect_kind, detect_kinds, find_repos, test_repo as run_one_repo, ) @@ -291,3 +291,97 @@ def test_make_reports_its_exit_code_not_an_invented_count(self, tmp_path): r = run_one_repo("m", str(d)) assert r.tests_run == 0 assert "exit 0" in r.reason + + +class TestNestedComponents: + """A build system below the repo root must still be found. + + detect_kinds looked only at the root, so eos-health (CMake firmware under + firmware/build-system, a web app under apps/web), eos-aero (a web app four + levels down) and eCAD-Hardware-Products (pytest tests, no packaging file) + all reported "unknown". The runner found no runner for that, skipped them, + and the summary counted the skip alongside the passes. Three of nineteen + repos were never tested by the tool whose job is to test them. + """ + + def test_root_detection_is_returned_unchanged(self, tmp_path): + # The guarantee that makes this change safe: a repo detected at the + # root must keep taking exactly the path it takes today, so the scan + # cannot regress the repos that already work. + d = _repo(tmp_path, "eos", "CMakeLists.txt") + assert detect_components(str(d)) == [("cmake", str(d))] + + def test_root_detection_wins_over_anything_nested(self, tmp_path): + d = _repo(tmp_path, "ebuild", "CMakeLists.txt", "pyproject.toml") + (d / "vendored").mkdir() + (d / "vendored" / "package.json").write_text("{}", encoding="utf-8") + kinds = [k for k, _ in detect_components(str(d))] + assert kinds == ["cmake", "python"] + assert "node" not in kinds + + def test_nested_component_carries_its_own_directory(self, tmp_path): + # The runners build from the directory handed to them. Reporting + # "cmake" without the location would send cmake -S at the repo root, + # which has no CMakeLists.txt — a spurious failure in place of a + # silent skip is not an improvement. + d = _repo(tmp_path, "eos-health") + nested = d / "firmware" / "build-system" + nested.mkdir(parents=True) + (nested / "CMakeLists.txt").write_text("", encoding="utf-8") + assert detect_components(str(d)) == [("cmake", str(nested))] + + def test_several_nested_components_are_all_reported(self, tmp_path): + d = _repo(tmp_path, "eos-health") + for sub, marker in (("firmware/build-system", "CMakeLists.txt"), + ("apps/web", "package.json")): + p = d / sub + p.mkdir(parents=True) + (p / marker).write_text("", encoding="utf-8") + assert sorted(k for k, _ in detect_components(str(d))) == ["cmake", "node"] + + def test_vendored_directories_are_not_components(self, tmp_path): + # A package.json under node_modules belongs to a dependency, not to + # the repo. Reporting it would have the runner test someone else's code. + d = _repo(tmp_path, "eOffice") + vendored = d / "node_modules" / "left-pad" + vendored.mkdir(parents=True) + (vendored / "package.json").write_text("{}", encoding="utf-8") + assert detect_components(str(d)) == [("unknown", str(d))] + + def test_scan_depth_is_bounded(self, tmp_path): + d = _repo(tmp_path, "deep") + buried = d / "a" / "b" / "c" / "d" / "e" + buried.mkdir(parents=True) + (buried / "package.json").write_text("{}", encoding="utf-8") + assert detect_components(str(d)) == [("unknown", str(d))] + + def test_a_repo_with_nothing_still_reports_unknown(self, tmp_path): + d = _repo(tmp_path, "docs-only", "README.md") + assert detect_components(str(d)) == [("unknown", str(d))] + + +class TestPythonTestsWithoutPackaging: + """pytest projects with no pyproject.toml are still pytest projects.""" + + def test_tests_directory_alone_identifies_python(self, tmp_path): + # test_python_repo only ever required a tests/ directory. Demanding + # pyproject.toml to reach it was the detector asking for something the + # runner does not use. + d = _repo(tmp_path, "eCAD-Hardware-Products") + (d / "tests").mkdir() + (d / "tests" / "test_rtl_models.py").write_text("", encoding="utf-8") + assert detect_kinds(str(d)) == ["python"] + + def test_a_tests_directory_without_python_does_not_count(self, tmp_path): + d = _repo(tmp_path, "thing") + (d / "tests").mkdir() + (d / "tests" / "test_main.c").write_text("", encoding="utf-8") + assert detect_kinds(str(d)) == ["unknown"] + + def test_c_repo_with_c_tests_does_not_also_become_python(self, tmp_path): + # eBoot has a CMakeLists.txt and a tests/ directory full of C. Pointing + # pytest at it would report a failure that means nothing. + d = _repo(tmp_path, "eBoot", "CMakeLists.txt") + (d / "tests").mkdir() + (d / "tests" / "helper.py").write_text("", encoding="utf-8") + assert detect_kinds(str(d)) == ["cmake"] From d3c9b3b30cf5c6ed6533b6ce544a4097ba8cab96 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 07:15:14 -0700 Subject: [PATCH 7/9] fix(ecosystem): an absent vendor SDK is not a broken build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing eos-health for the first time — it was one of the three repos the previous commit unskipped — produced three CMake failures that the runner reported identically as FAIL: eos-health/firmware/build-system FAIL cmake configure failed eos-health/devices/health-band-neuro/firmware FAIL cmake configure failed eos-health/firmware/health-band-neuro/tests FAIL cmake configure failed They are not the same thing. The first stops on NRF5_SDK_PATH not set. Download nRF5 SDK 17.1.0 which means install something. The other two stop on Cannot find source file: src/main.c Cannot find source file: .../src/ecg/ecg_hrv.c which means the CMakeLists and the tree disagree, and no installation will help. Putting both in the same column costs the reader the one distinction that decides what to do next. The runner already draws exactly this line for Python — DEPS, "a narrower SKIP: the suite exists and would run, but the repo's dependencies are absent" — and for node's npm ci. CMake was the odd one out. _unmet_toolchain() names the absent dependency from an unset *_SDK/_ROOT/ _DIR/_PATH/_HOME variable or a failed find_package, and the configure step reports DEPS instead of FAIL when it finds one. Deliberately narrow. "Cannot find source file" and "No SOURCES given" are checked first and force None, so a repository referencing code it does not contain stays a failure. eos-health emits both kinds in one configure run, and the repo defect has to be the one that survives — a status reading "not our fault" over a real defect is worse than no classification at all. That case is pinned by a test. tests/unit/test_ecosystem_runner.py 54 -> 61 full suite 1720 -> 1727 passed, 0 failed Refs #17 Co-Authored-By: Claude Opus 5 (1M context) --- eosim/integrations/ecosystem.py | 40 ++++++++++++++++++++++++-- tests/unit/test_ecosystem_runner.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index eeb6592..e21a17f 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -312,8 +312,17 @@ def test_c_repo(name: str, path: str) -> RepoTestResult: result.duration_s = time.time() - start return result if r.returncode != 0: - result.status, result.reason = FAIL, "cmake configure failed" - result.output = (r.stderr or r.stdout)[-2000:] + output = (r.stderr or "") + (r.stdout or "") + unmet = _unmet_toolchain(output) + if unmet: + # An absent vendor SDK is not a broken build. Reporting it as FAIL + # puts it in the same column as a CMakeLists that references + # sources which do not exist, and the two need opposite responses: + # one is "install something", the other is "fix the repo". + result.status, result.reason = DEPS, "needs %s" % unmet + else: + result.status, result.reason = FAIL, "cmake configure failed" + result.output = output[-2000:] result.duration_s = time.time() - start return result @@ -437,6 +446,33 @@ def test_python_repo(name: str, path: str) -> RepoTestResult: return result +# What a CMakeLists says when an external SDK or toolchain it needs is absent. +# Deliberately narrow: it must not swallow "Cannot find source file", which +# means the repository is referencing code it does not contain. +_UNMET_TOOLCHAIN_PATTERNS = ( + re.compile(r"(?P[A-Z][A-Z0-9_]*_(?:SDK|TOOLCHAIN|ROOT|DIR|PATH|HOME)" + r"[A-Z0-9_]*)\s+not set"), + re.compile(r"Could NOT find (?P[A-Za-z0-9_+-]+)"), + re.compile(r"(?P[A-Za-z0-9_+-]+) is required but was not found"), +) + + +def _unmet_toolchain(output: str): + """The name of an absent external dependency, or None. + + Returns None when the failure is anything the repository itself owns. A + missing source file in particular must stay a FAIL: it means the build + description and the tree disagree, which no amount of installing fixes. + """ + if "Cannot find source file" in output or "No SOURCES given" in output: + return None + for pattern in _UNMET_TOOLCHAIN_PATTERNS: + match = pattern.search(output) + if match: + return match.group("name") + return None + + def _missing_modules(out: str) -> list: """Module names pytest could not import, deduplicated and ordered.""" seen = [] diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py index 822d546..4538bd9 100644 --- a/tests/unit/test_ecosystem_runner.py +++ b/tests/unit/test_ecosystem_runner.py @@ -31,6 +31,7 @@ DEPS, ERROR, FAIL, PASS, SKIP, EcosystemReport, RepoTestResult, _parse_ctest, _parse_pytest, + _unmet_toolchain, detect_components, detect_kind, detect_kinds, find_repos, test_repo as run_one_repo, ) @@ -385,3 +386,46 @@ def test_c_repo_with_c_tests_does_not_also_become_python(self, tmp_path): (d / "tests").mkdir() (d / "tests" / "helper.py").write_text("", encoding="utf-8") assert detect_kinds(str(d)) == ["cmake"] + + +class TestUnmetToolchainIsNotABrokenBuild: + """An absent vendor SDK and a missing source file need opposite responses. + + eos-health produced both at once. firmware/build-system stops on + "NRF5_SDK_PATH not set", which means install something; two other trees stop + on "Cannot find source file", which means the CMakeLists and the tree + disagree and no installation will help. Reporting both as FAIL puts them in + the same column. + """ + + def test_an_unset_sdk_path_is_named(self): + assert _unmet_toolchain( + "NRF5_SDK_PATH not set. Download nRF5 SDK 17.1.0" + ) == "NRF5_SDK_PATH" + + def test_an_unset_toolchain_root_is_named(self): + assert _unmet_toolchain("ARM_TOOLCHAIN_ROOT not set") == "ARM_TOOLCHAIN_ROOT" + + def test_a_find_package_failure_is_named(self): + assert _unmet_toolchain("Could NOT find OpenSSL") == "OpenSSL" + + def test_a_missing_source_file_stays_a_failure(self): + # The repository is referencing code it does not contain. Classifying + # that as a dependency problem would hide a real defect behind a status + # that reads as "not our fault". + assert _unmet_toolchain( + "CMake Error at CMakeLists.txt:65 (add_executable):\n" + " Cannot find source file:\n src/main.c") is None + + def test_a_target_with_no_sources_stays_a_failure(self): + assert _unmet_toolchain( + "No SOURCES given to target: health_band_neuro.elf") is None + + def test_a_repo_defect_wins_when_both_appear(self): + # eos-health emits both in one configure run. The repo defect is the + # one that must survive the classification. + assert _unmet_toolchain( + "NRF5_SDK_PATH not set\nCannot find source file: a.c") is None + + def test_an_unrecognised_failure_stays_a_failure(self): + assert _unmet_toolchain("CMake Error: something else entirely") is None From dd750b20e9906b9dc0d88099792e8e59483cb072 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 07:15:51 -0700 Subject: [PATCH 8/9] docs: document the ecosystem runner, and fix the repo table's casing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README described EoSim's platforms and engines but said nothing about the ecosystem runner, which is the part that tests the organisation rather than a simulation. Adds a section covering how repositories and their build systems are discovered, that a repo with several build systems is exercised through all of them, and what the five statuses mean — in particular why DEPS is kept apart from FAIL. "Install the nRF5 SDK" and "this CMakeLists references a source file that does not exist" are opposite problems and a single red status would hide which one you have. Also records that counts come from the runner's own output and never from an exit status, since the inverse was a real defect here: tests_passed was once inferred from a successful build, so every repo that compiled reported a passing suite whether or not it ran a single test. The ecosystem table listed eboot, eipc, eai and eni in lowercase. GitHub redirects those, so the links worked, but the casing is wrong on disk and this is not a cosmetic detail in this repository: find_repos held a hardcoded lowercase list and consequently discovered 2 of 19 repos on a case-sensitive filesystem. Corrected, with a note saying why. eFirmware and eDB were missing from the table and are added. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4e1d57..ddbec19 100644 --- a/README.md +++ b/README.md @@ -212,15 +212,63 @@ The optional Tkinter GUI provides: | Repo | Description | |---|---| | [eos](https://github.com/embeddedos-org/eos) | Embedded OS — HAL, RTOS kernel, drivers, services | -| [eboot](https://github.com/embeddedos-org/eboot) | Bootloader — 24 board ports, secure boot, A/B slots | +| [eBoot](https://github.com/embeddedos-org/eBoot) | Bootloader — secure boot, A/B slots, recovery | | [ebuild](https://github.com/embeddedos-org/ebuild) | Build system — SDK generator, packaging | -| [eipc](https://github.com/embeddedos-org/eipc) | IPC framework — Go + C SDK, HMAC auth | -| [eai](https://github.com/embeddedos-org/eai) | AI layer — LLM inference, agent loop | -| [eni](https://github.com/embeddedos-org/eni) | Neural interface — BCI, Neuralink adapter | -| [eApps](https://github.com/embeddedos-org/eApps) | Cross-platform apps — 38 C + LVGL apps | -| [EoStudio](https://github.com/embeddedos-org/EoStudio) | Design suite — 10 editors with LLM integration | +| [eFirmware](https://github.com/embeddedos-org/eFirmware) | `.efw` image format and `efwtool` | +| [eIPC](https://github.com/embeddedos-org/eIPC) | IPC framework — Go + C SDK, HMAC auth | +| [eAI](https://github.com/embeddedos-org/eAI) | AI layer — LLM inference, agent loop | +| [eNI](https://github.com/embeddedos-org/eNI) | Neural interface — BCI adapters | +| [eDB](https://github.com/embeddedos-org/eDB) | Embedded data and storage service | +| [eApps](https://github.com/embeddedos-org/eApps) | Cross-platform apps — C + LVGL | +| [EoStudio](https://github.com/embeddedos-org/EoStudio) | Design suite — editors with LLM integration | | **EoSim** | **Simulation platform (this repo)** | +Repository names are case-sensitive on Linux filesystems. They are written here +exactly as they are on disk, because getting that wrong is not a cosmetic +mistake: the ecosystem runner once held a hardcoded list of lowercase names +(`eboot`, `eipc`, `eni`) and consequently discovered 2 of the 19 repositories, +silently skipping the rest. + +### Testing the whole organisation + +`eosim.integrations.ecosystem` builds and tests every repository in the +workspace, so one command covers the platform rather than each repo separately. + +```python +from eosim.integrations.ecosystem import find_repos, test_repo_all + +for name, path in sorted(find_repos("/path/to/workspace").items()): + for result in test_repo_all(name, path): + print(result.repo, result.kind, result.status, + result.tests_passed, result.tests_failed) +``` + +Repositories are found by inspecting the workspace, not from a list, and each +one's build systems are detected from the files it actually contains — +including below the root, so a repo whose firmware lives in +`firmware/build-system/` is built rather than skipped. A repo with more than one +build system is exercised through all of them; reporting only the primary is how +a broken CMake build stays invisible behind a green Python suite. + +Statuses are deliberately distinct, because they call for different responses: + +| Status | Meaning | +|---|---| +| `PASS` | Built and its tests passed | +| `FAIL` | Built and its tests failed, or the repo's own build is broken | +| `DEPS` | The suite would run, but a dependency is absent from this environment — an unset `*_SDK_PATH`, a failed `find_package`, an uninstalled Python module, `npm ci` not run | +| `SKIP` | Nothing to test | +| `ERROR` | The runner itself could not complete | + +`DEPS` is separated from `FAIL` on purpose. "Install the nRF5 SDK" and "this +CMakeLists references a source file that does not exist" are opposite problems, +and a single red status would hide which one you have. + +Counts are whatever the underlying runner printed. They are never derived from +an exit status — an earlier version inferred `tests_passed` from a successful +build, which made every repo that compiled report a passing suite whether or not +it had run a single test. + ## Security ### QEMU Connection Security From 1dfcce7b1df7a0db44671315cb36fd365944ae92 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 07:18:50 -0700 Subject: [PATCH 9/9] fix(ecosystem): an uninstalled test runner is not a failing suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full ecosystem run reported five repositories as FAIL: EoSim FAIL pytest produced no summary (exit 1) EoStudio FAIL pytest produced no summary (exit 1) eDB FAIL pytest produced no summary (exit 1) ebuild FAIL pytest produced no summary (exit 1) eosllm FAIL pytest produced no summary (exit 1) None of them is failing. EoStudio's suite is 729 passed, run directly. The runner's interpreter simply has no pytest installed: /usr/bin/python3: No module named pytest DEPS already exists for this — "the suite exists and would run, but the repo's dependencies are absent" — but it is only reachable after the counts are parsed. A collection error aborts pytest before it prints any summary, so that branch is never entered and everything lands on FAIL. The previous commit fixed the same shape for cmake; this is the Python half. Two changes. The no-summary branch now checks for absent modules and reports DEPS naming them. It asks _external_missing_modules(), which excludes anything the repo itself provides — a package directory, a src/ layout, or a single module file. The runner puts the checkout on PYTHONPATH, so a repo failing to import its own package is a real defect and has to stay a FAIL. A repo's own absence does not mask a third-party one; both are pinned by tests. _missing_modules() now matches the unquoted spelling too. ModuleNotFoundError quotes the name, but `python -m pytest` on an interpreter without pytest prints "No module named pytest" bare. That is precisely the case where the runner itself is what is missing and nothing else in the output explains the failure, and the quoted-only pattern walked past it. EoStudio DEPS needs pytest eDB DEPS needs pytest eosllm DEPS needs pytest tests/unit/test_ecosystem_runner.py 61 -> 71 full suite 1727 -> 1737 passed, 0 failed Refs #17 Co-Authored-By: Claude Opus 5 (1M context) --- eosim/integrations/ecosystem.py | 47 ++++++++++++++++++++-- tests/unit/test_ecosystem_runner.py | 62 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index e21a17f..e08db43 100644 --- a/eosim/integrations/ecosystem.py +++ b/eosim/integrations/ecosystem.py @@ -421,7 +421,18 @@ def test_python_repo(name: str, path: str) -> RepoTestResult: else "pytest produced no summary (exit %d)" % r.returncode) if r.returncode in (0, 5): return _skip(result, reason, start) - result.status, result.reason = FAIL, reason + + # A collection error caused by absent third-party modules is the same + # condition DEPS already covers further down, reached earlier: pytest + # aborts before printing any summary, so the counts never get parsed. + # Calling it FAIL puts "pip install click" in the same column as a + # suite that genuinely fails. + external = _external_missing_modules(result.output, path) + if external: + result.status = DEPS + result.reason = "needs %s" % ", ".join(external) + else: + result.status, result.reason = FAIL, reason result.duration_s = time.time() - start return result @@ -474,15 +485,43 @@ def _unmet_toolchain(output: str): def _missing_modules(out: str) -> list: - """Module names pytest could not import, deduplicated and ordered.""" + """Module names that could not be imported, deduplicated and ordered. + + Both spellings are matched. ModuleNotFoundError quotes the name, but + `python -m pytest` on an interpreter without pytest prints the bare form + "No module named pytest" — the case where the test runner itself is what is + absent, which is exactly when nothing else in the output explains the + failure. + """ seen = [] - for name in re.findall(r"No module named '([^']+)'", out): - top = name.split(".")[0] + for quoted, bare in re.findall( + r"No module named (?:'([^']+)'|([A-Za-z_][\w.]*))", out): + top = (quoted or bare).split(".")[0] if top not in seen: seen.append(top) return seen +def _external_missing_modules(out: str, repo_path: str) -> list: + """Absent modules that the repository does not itself provide. + + A repo failing to import its own package is a real defect — the runner + already puts the checkout on PYTHONPATH, so that import should resolve — + and must stay a FAIL. Only third-party absences are an environment + problem. + """ + external = [] + for name in _missing_modules(out): + provided = ( + os.path.isdir(os.path.join(repo_path, name)) + or os.path.isfile(os.path.join(repo_path, name + ".py")) + or os.path.isdir(os.path.join(repo_path, "src", name)) + ) + if not provided: + external.append(name) + return external + + def _parse_pytest(out: str): """(passed, failed, errors) from pytest's summary line, or None. diff --git a/tests/unit/test_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py index 4538bd9..f047bc6 100644 --- a/tests/unit/test_ecosystem_runner.py +++ b/tests/unit/test_ecosystem_runner.py @@ -31,6 +31,8 @@ DEPS, ERROR, FAIL, PASS, SKIP, EcosystemReport, RepoTestResult, _parse_ctest, _parse_pytest, + _external_missing_modules, + _missing_modules, _unmet_toolchain, detect_components, detect_kind, detect_kinds, find_repos, test_repo as run_one_repo, @@ -429,3 +431,63 @@ def test_a_repo_defect_wins_when_both_appear(self): def test_an_unrecognised_failure_stays_a_failure(self): assert _unmet_toolchain("CMake Error: something else entirely") is None + + +class TestAbsentThirdPartyModulesAreNotFailures: + """pytest aborting on an uninstalled dependency is a DEPS, not a FAIL. + + When collection fails, pytest prints no summary at all, so the counts are + never parsed and the DEPS branch further down is never reached. Five repos + in a full ecosystem run were reported FAIL for "No module named 'click'". + """ + + def test_a_third_party_module_is_reported(self, tmp_path): + out = "E ModuleNotFoundError: No module named 'click'" + assert _external_missing_modules(out, str(tmp_path)) == ["click"] + + def test_the_repos_own_package_is_not(self, tmp_path): + # The runner puts the checkout on PYTHONPATH, so a repo failing to + # import its own package is a real defect and must stay a FAIL. + (tmp_path / "eostudio").mkdir() + out = "E ModuleNotFoundError: No module named 'eostudio'" + assert _external_missing_modules(out, str(tmp_path)) == [] + + def test_a_src_layout_package_is_recognised_as_the_repos_own(self, tmp_path): + (tmp_path / "src" / "mypkg").mkdir(parents=True) + out = "No module named 'mypkg'" + assert _external_missing_modules(out, str(tmp_path)) == [] + + def test_a_single_module_file_counts_as_the_repos_own(self, tmp_path): + (tmp_path / "helper.py").write_text("", encoding="utf-8") + out = "No module named 'helper'" + assert _external_missing_modules(out, str(tmp_path)) == [] + + def test_the_repos_own_absence_does_not_mask_a_third_party_one(self, tmp_path): + (tmp_path / "eostudio").mkdir() + out = ("No module named 'eostudio'\n" + "No module named 'click'") + assert _external_missing_modules(out, str(tmp_path)) == ["click"] + + def test_nothing_missing_yields_nothing(self, tmp_path): + assert _external_missing_modules("all fine", str(tmp_path)) == [] + + +class TestMissingModuleSpellings: + """Both forms of Python's message, including the one that matters most.""" + + def test_the_quoted_form(self): + assert _missing_modules("No module named 'click'") == ["click"] + + def test_the_bare_form(self): + # `python -m pytest` on an interpreter without pytest prints no quotes. + # This is the case where the test runner itself is absent — exactly + # when nothing else in the output explains the failure — and missing it + # reported five repos as FAIL for an environment problem. + assert _missing_modules("No module named pytest") == ["pytest"] + + def test_a_dotted_name_reduces_to_its_top_level(self): + assert _missing_modules("No module named 'a.b.c'") == ["a"] + + def test_both_forms_together_and_deduplicated(self): + out = "No module named pytest\nNo module named 'click'\nNo module named 'click'" + assert _missing_modules(out) == ["pytest", "click"]