Rename package from mcp-ltspice/mcp_ltspice to mcltspice throughout: source directory, imports, pyproject.toml, tests, and README. Remove startup banner prints from main() since FastMCP handles its own banner and stdout is the MCP JSON-RPC transport. Point repo URL at git.supported.systems/MCP/mcltspice.
176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
"""Tests for asc_generator module: pin positioning, schematic rendering, templates."""
|
|
|
|
import pytest
|
|
|
|
from mcltspice.asc_generator import (
|
|
_PIN_OFFSETS,
|
|
AscSchematic,
|
|
_rotate,
|
|
generate_inverting_amp,
|
|
generate_rc_lowpass,
|
|
generate_voltage_divider,
|
|
pin_position,
|
|
)
|
|
|
|
|
|
class TestPinPosition:
|
|
@pytest.mark.parametrize("symbol", ["voltage", "res", "cap", "ind"])
|
|
def test_r0_returns_offset_plus_origin(self, symbol):
|
|
"""At R0, pin position = origin + raw offset."""
|
|
cx, cy = 160, 80
|
|
for pin_idx in range(2):
|
|
px, py = pin_position(symbol, pin_idx, cx, cy, rotation=0)
|
|
offsets = _PIN_OFFSETS[symbol]
|
|
ox, oy = offsets[pin_idx]
|
|
assert px == cx + ox
|
|
assert py == cy + oy
|
|
|
|
@pytest.mark.parametrize("symbol", ["voltage", "res", "cap", "ind"])
|
|
def test_r90(self, symbol):
|
|
"""R90 applies (px, py) -> (-py, px)."""
|
|
cx, cy = 160, 80
|
|
for pin_idx in range(2):
|
|
px, py = pin_position(symbol, pin_idx, cx, cy, rotation=90)
|
|
offsets = _PIN_OFFSETS[symbol]
|
|
ox, oy = offsets[pin_idx]
|
|
assert px == cx + (-oy)
|
|
assert py == cy + ox
|
|
|
|
@pytest.mark.parametrize("symbol", ["voltage", "res", "cap", "ind"])
|
|
def test_r180(self, symbol):
|
|
"""R180 applies (px, py) -> (-px, -py)."""
|
|
cx, cy = 160, 80
|
|
for pin_idx in range(2):
|
|
px, py = pin_position(symbol, pin_idx, cx, cy, rotation=180)
|
|
offsets = _PIN_OFFSETS[symbol]
|
|
ox, oy = offsets[pin_idx]
|
|
assert px == cx + (-ox)
|
|
assert py == cy + (-oy)
|
|
|
|
@pytest.mark.parametrize("symbol", ["voltage", "res", "cap", "ind"])
|
|
def test_r270(self, symbol):
|
|
"""R270 applies (px, py) -> (py, -px)."""
|
|
cx, cy = 160, 80
|
|
for pin_idx in range(2):
|
|
px, py = pin_position(symbol, pin_idx, cx, cy, rotation=270)
|
|
offsets = _PIN_OFFSETS[symbol]
|
|
ox, oy = offsets[pin_idx]
|
|
assert px == cx + oy
|
|
assert py == cy + (-ox)
|
|
|
|
def test_unknown_symbol_defaults(self):
|
|
"""Unknown symbol uses default pin offsets."""
|
|
px, py = pin_position("unknown", 0, 0, 0, rotation=0)
|
|
# Default is [(0, 0), (0, 80)]
|
|
assert (px, py) == (0, 0)
|
|
px2, py2 = pin_position("unknown", 1, 0, 0, rotation=0)
|
|
assert (px2, py2) == (0, 80)
|
|
|
|
|
|
class TestRotate:
|
|
def test_identity(self):
|
|
assert _rotate(10, 20, 0) == (10, 20)
|
|
|
|
def test_90(self):
|
|
assert _rotate(10, 20, 90) == (-20, 10)
|
|
|
|
def test_180(self):
|
|
assert _rotate(10, 20, 180) == (-10, -20)
|
|
|
|
def test_270(self):
|
|
assert _rotate(10, 20, 270) == (20, -10)
|
|
|
|
def test_invalid_rotation(self):
|
|
"""Invalid rotation falls through to identity."""
|
|
assert _rotate(10, 20, 45) == (10, 20)
|
|
|
|
|
|
class TestAscSchematicRender:
|
|
def test_version_header(self):
|
|
sch = AscSchematic()
|
|
text = sch.render()
|
|
assert text.startswith("Version 4\n")
|
|
|
|
def test_sheet_dimensions(self):
|
|
sch = AscSchematic(sheet_w=1200, sheet_h=900)
|
|
text = sch.render()
|
|
assert "SHEET 1 1200 900" in text
|
|
|
|
def test_wire_rendering(self):
|
|
sch = AscSchematic()
|
|
sch.add_wire(80, 96, 176, 96)
|
|
text = sch.render()
|
|
assert "WIRE 80 96 176 96" in text
|
|
|
|
def test_component_rendering(self):
|
|
sch = AscSchematic()
|
|
sch.add_component("res", "R1", "1k", 160, 80)
|
|
text = sch.render()
|
|
assert "SYMBOL res 160 80 R0" in text
|
|
assert "SYMATTR InstName R1" in text
|
|
assert "SYMATTR Value 1k" in text
|
|
|
|
def test_rotated_component(self):
|
|
sch = AscSchematic()
|
|
sch.add_component("res", "R1", "1k", 160, 80, rotation=90)
|
|
text = sch.render()
|
|
assert "SYMBOL res 160 80 R90" in text
|
|
|
|
def test_ground_flag(self):
|
|
sch = AscSchematic()
|
|
sch.add_ground(80, 176)
|
|
text = sch.render()
|
|
assert "FLAG 80 176 0" in text
|
|
|
|
def test_net_label(self):
|
|
sch = AscSchematic()
|
|
sch.add_net_label("out", 176, 176)
|
|
text = sch.render()
|
|
assert "FLAG 176 176 out" in text
|
|
|
|
def test_directive_rendering(self):
|
|
sch = AscSchematic()
|
|
sch.add_directive(".tran 10m", 80, 300)
|
|
text = sch.render()
|
|
assert "TEXT 80 300 Left 2 !.tran 10m" in text
|
|
|
|
def test_chaining(self):
|
|
sch = (
|
|
AscSchematic()
|
|
.add_component("res", "R1", "1k", 160, 80)
|
|
.add_wire(80, 96, 176, 96)
|
|
.add_ground(80, 176)
|
|
)
|
|
text = sch.render()
|
|
assert "SYMBOL" in text
|
|
assert "WIRE" in text
|
|
assert "FLAG" in text
|
|
|
|
|
|
class TestAscTemplates:
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[generate_rc_lowpass, generate_voltage_divider, generate_inverting_amp],
|
|
)
|
|
def test_template_returns_schematic(self, factory):
|
|
sch = factory()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[generate_rc_lowpass, generate_voltage_divider, generate_inverting_amp],
|
|
)
|
|
def test_template_nonempty(self, factory):
|
|
text = factory().render()
|
|
assert len(text) > 50
|
|
assert "SYMBOL" in text
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[generate_rc_lowpass, generate_voltage_divider, generate_inverting_amp],
|
|
)
|
|
def test_template_has_expected_components(self, factory):
|
|
text = factory().render()
|
|
# All templates should have at least a res and a voltage source
|
|
assert "res" in text or "voltage" in text
|