mcltspice/tests/test_platform_config.py
Ryan Malloy 37caaf0310 Add native macOS support (no Wine) per Kevin's spec
Platform-branches the Wine-specific execution so Linux behaves identically
while macOS drives LTspice.app natively. Implemented from muel9560's measured
spec + validated where possible against a Wine -netlist oracle on Linux.

- config.py: get_launch() abstraction (argv prefix + env + path translator);
  IS_DARWIN, LTSPICE_BIN (env-overridable), LTSPICE_LIB_ROOT under
  ~/Library/Application Support/LTspice on macOS. Linux resolves to the
  historical Wine paths unchanged.
- runner.py: darwin branch uses the native binary, POSIX paths (no Z: map),
  plain -b (never -Run), and completion by polling for a stable .raw (the Mac
  GUI app's exit code is unreliable). Linux keeps communicate()+wait_for.
- netlister.py (new): asc_to_netlist() converts a schematic to a netlist on
  macOS, since the Mac CLI can't netlist a .asc. Union-find net extraction;
  all 15 templates netlist, topology matches the Wine -netlist oracle.
- library_tools.py: check_installation reports platform/binary/lib-root/
  version/gui-session on macOS.
- packaging/docs: drop 'Linux only'; document LTSPICE_BIN + GUI-session need.

Adversarial review fixes: restore kill+reap of the Wine subprocess on Linux
timeout (was orphaning the process -- a real regression); alias duplicate net
labels instead of aborting (sallen-key); synthesize NC_xx nodes for floating
pins (op-amps) to match the oracle; use asyncio.get_running_loop().

Linux unaffected: 476 unit + 10 integration (real Wine) still pass; +48 unit
+8 integration for the platform/netlister paths. macOS runtime (LTspice.app
invocation, GUI session, poll timing) needs validation on Kevin's hardware.
2026-07-03 12:35:18 -06:00

223 lines
8.7 KiB
Python

"""Platform resolution + path-translation tests for the macOS native port.
These assert that config.py resolves the right binary, argv prefix, lib root,
environment, and path-translation strategy on each platform -- and that the
LTSPICE_BIN / LTSPICE_LIB_ROOT env overrides win. No LTspice or Wine is needed:
config is re-imported under a monkeypatched sys.platform so the module-level
platform branch re-evaluates.
Regression guardrails for the HARD CONSTRAINT: the Linux/Wine path must stay
byte-for-byte unchanged -- Wine argv, WINE* env, and the "Z:" drive mapping.
"""
import importlib
import sys
from pathlib import Path
import pytest
def _reimport_config(monkeypatch, platform: str, env: dict[str, str] | None = None):
"""Re-import mcltspice.config with sys.platform and env forced.
config.py computes IS_DARWIN / LTSPICE_BIN / LTSPICE_LIB_ROOT at import
time, so the module must be reloaded after patching sys.platform for the
platform branch to take effect. Returns the freshly-reloaded module.
"""
monkeypatch.setattr(sys, "platform", platform)
# Clear any LTspice env the host may have set, then apply the test's env.
for var in (
"LTSPICE_DIR",
"LTSPICE_BIN",
"LTSPICE_LIB_ROOT",
"LTSPICE_EXAMPLES",
):
monkeypatch.delenv(var, raising=False)
for key, value in (env or {}).items():
monkeypatch.setenv(key, value)
import mcltspice.config as config_module
return importlib.reload(config_module)
@pytest.fixture(autouse=True)
def _restore_config():
"""Reload config with the real sys.platform after each test.
Prevents a monkeypatched darwin/linux config from leaking into other test
modules that import mcltspice.config.
"""
yield
import mcltspice.config as config_module
importlib.reload(config_module)
# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
class TestPlatformDetection:
def test_darwin_sets_is_darwin(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
assert cfg.IS_DARWIN is True
def test_linux_clears_is_darwin(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "linux")
assert cfg.IS_DARWIN is False
# ---------------------------------------------------------------------------
# Binary resolution + argv prefix
# ---------------------------------------------------------------------------
class TestBinaryResolution:
def test_darwin_default_binary(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
assert cfg.LTSPICE_BIN == Path(
"/Applications/LTspice.app/Contents/MacOS/LTspice"
)
def test_darwin_binary_env_override_wins(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
)
assert cfg.LTSPICE_BIN == Path("/opt/custom/LTspice")
def test_darwin_argv_prefix_is_bare_binary_no_wine(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
launch = cfg.get_launch()
assert launch.is_darwin is True
assert launch.argv_prefix == [str(cfg.LTSPICE_BIN)]
# No wine anywhere in the invocation on darwin.
assert "wine" not in launch.argv_prefix
assert launch.argv_prefix[0].endswith("LTspice")
def test_darwin_argv_prefix_honors_bin_override(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
)
launch = cfg.get_launch()
assert launch.argv_prefix == ["/opt/custom/LTspice"]
def test_linux_argv_prefix_is_wine_exe(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "linux")
launch = cfg.get_launch()
assert launch.is_darwin is False
assert launch.argv_prefix[0] == "wine"
assert launch.argv_prefix[1] == str(cfg.LTSPICE_EXE)
assert launch.argv_prefix[1].endswith("LTspice.exe")
def test_linux_dir_env_override_flows_to_exe(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
)
assert cfg.LTSPICE_DIR == Path("/custom/ltspice")
assert cfg.LTSPICE_EXE == Path("/custom/ltspice/LTspice.exe")
launch = cfg.get_launch()
assert launch.argv_prefix == ["wine", "/custom/ltspice/LTspice.exe"]
# ---------------------------------------------------------------------------
# Lib-root resolution
# ---------------------------------------------------------------------------
class TestLibRootResolution:
def test_darwin_lib_root_default_outside_app_bundle(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
expected = Path.home() / "Library" / "Application Support" / "LTspice"
assert cfg.LTSPICE_LIB_ROOT == expected
# lib lives UNDER the resolved root, not glued onto the executable dir.
assert cfg.LTSPICE_LIB == expected / "lib"
def test_darwin_lib_root_env_override_wins(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"}
)
assert cfg.LTSPICE_LIB_ROOT == Path("/data/ltspice-lib")
assert cfg.LTSPICE_LIB == Path("/data/ltspice-lib/lib")
def test_darwin_examples_under_lib_root(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"}
)
assert cfg.LTSPICE_EXAMPLES == Path("/data/ltspice-lib/examples")
def test_linux_lib_root_defaults_to_dir(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
)
# Historical behavior: LTSPICE_LIB resolves to LTSPICE_DIR / "lib".
assert cfg.LTSPICE_LIB == Path("/custom/ltspice/lib")
assert cfg.LTSPICE_EXAMPLES == Path("/custom/ltspice/examples")
def test_lib_root_is_resolved_value_not_string_concat(self, monkeypatch):
"""Lib root must be a Path derived from an env-overridable root, not a
string glued onto the binary's directory."""
cfg = _reimport_config(
monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
)
# Changing the binary must NOT drag the lib root along with it.
assert str(cfg.LTSPICE_BIN.parent) not in str(cfg.LTSPICE_LIB_ROOT)
# ---------------------------------------------------------------------------
# Environment: Wine vars only on Linux
# ---------------------------------------------------------------------------
class TestLaunchEnv:
def test_darwin_env_has_no_wine_vars(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
launch = cfg.get_launch()
assert "WINEPREFIX" not in launch.env
assert "WINEARCH" not in launch.env
assert "WINEDEBUG" not in launch.env
def test_linux_env_sets_wine_vars(self, monkeypatch):
cfg = _reimport_config(
monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
)
launch = cfg.get_launch()
assert launch.env["WINEPREFIX"] == str(cfg.WINE_PREFIX)
assert launch.env["WINEARCH"] == "win64"
assert "WINEDEBUG" in launch.env
# ---------------------------------------------------------------------------
# Path translation: identity on macOS, Z:\ mapping on Linux
# ---------------------------------------------------------------------------
class TestPathTranslation:
def test_darwin_path_translation_is_identity(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "darwin")
launch = cfg.get_launch()
p = Path("/Users/kevin/work/circuit.net")
assert launch.translate_path(p) == "/Users/kevin/work/circuit.net"
# No drive letter, no backslashes.
assert "Z:" not in launch.translate_path(p)
assert "\\" not in launch.translate_path(p)
def test_linux_path_translation_maps_z_drive(self, monkeypatch):
cfg = _reimport_config(monkeypatch, "linux")
launch = cfg.get_launch()
p = Path("/home/rpm/work/circuit.net")
translated = launch.translate_path(p)
assert translated == "Z:\\home\\rpm\\work\\circuit.net"
assert translated.startswith("Z:")
assert "/" not in translated
def test_linux_translation_matches_historical_wine_mapping(self, monkeypatch):
"""Byte-for-byte reproduction of the historical
'Z:' + str(path).replace('/', chr(92)) transform."""
cfg = _reimport_config(monkeypatch, "linux")
launch = cfg.get_launch()
p = Path("/tmp/mcltspice_work/abc/def.raw")
expected = "Z:" + str(p).replace("/", chr(92))
assert launch.translate_path(p) == expected