diff --git a/.coverage b/.coverage index f976fd1..39c3cbc 100644 Binary files a/.coverage and b/.coverage differ 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/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 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..fdf9dd0 100644 --- a/eosim/cli/main.py +++ b/eosim/cli/main.py @@ -1,954 +1,1013 @@ -# 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") +@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 ( + 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) + + 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("") + 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) + + +@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..57d678a 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,79 @@ 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. + undef_before = self.cpu.undefined_count + stepped = self.cpu.step() executed += 1 + if not stepped: + # 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 - # Tick timer if executed % 100 == 0: timer = self.peripherals.get('timer0') if timer: @@ -105,13 +151,27 @@ 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, 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)) return { - 'success': True, + 'success': success, + '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(), } @@ -139,8 +199,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/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/eosim/integrations/ecosystem.py b/eosim/integrations/ecosystem.py index b003d2d..e08db43 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,690 @@ 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 + total_blocked: 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" % ( - self.total_tests, self.total_passed, self.total_failed)) - lines.append(f" Time: {self.duration_s:.2f}s") + lines.append(" Repos: %d discovered | %d passed | %d failed | %d skipped" % ( + self.repos_tested, self.repos_passed, + self.repos_failed, self.repos_skipped)) + 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 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) 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)" % ( + 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_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"): + kinds.append("docs") + 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] + + +def _skip(result: RepoTestResult, reason: str, start: float) -> RepoTestResult: + result.status = SKIP + result.reason = reason + result.duration_s = time.time() - start + 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: - 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" + return _skip(result, "cmake not installed", start) + + 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"] + try: + 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 - 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"] - 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) + if r.returncode != 0: + 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 - # 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) + + # 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 + + 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 +# 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 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 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. + + 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([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 + + +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([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([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, +} + + +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, 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=label, kind=kind) + results.append( + _skip(r, "no runner for a '%s' project" % kind, time.time())) + else: + results.append(runner(label, comp_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 + if r.status == DEPS: + report.total_blocked += r.tests_failed + else: + 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/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_ecosystem_runner.py b/tests/unit/test_ecosystem_runner.py new file mode 100644 index 0000000..f047bc6 --- /dev/null +++ b/tests/unit/test_ecosystem_runner.py @@ -0,0 +1,493 @@ +# 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, + _external_missing_modules, + _missing_modules, + _unmet_toolchain, + detect_components, 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") + + +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() + + +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 + + +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"] + + +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 + + +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"] 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..b684b1a --- /dev/null +++ b/tests/unit/test_native_engine_execution.py @@ -0,0 +1,188 @@ +# 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 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))) + r = vm.run(max_cycles=10_000_000, timeout_s=0.05) + + assert r["reason"] in ("timeout", "cycle-limit") + assert r["success"] is False