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.
223 lines
8.7 KiB
Python
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
|