"""Tests for the pure-SVG waveform plot generation module.""" import numpy as np import pytest from mcltspice.svg_plot import ( _format_freq, _nice_ticks, plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def sine_wave(): """A 1 kHz sine wave sampled at 100 kHz for 10 ms.""" t = np.linspace(0, 0.01, 1000, endpoint=False) v = np.sin(2 * np.pi * 1000 * t) return t, v @pytest.fixture() def bode_data(): """Simple first-order lowpass Bode response (fc = 1 kHz).""" freq = np.logspace(1, 6, 500) fc = 1e3 mag_db = -10 * np.log10(1 + (freq / fc) ** 2) phase_deg = -np.degrees(np.arctan(freq / fc)) return freq, mag_db, phase_deg @pytest.fixture() def spectrum_data(): """Synthetic FFT spectrum with a peak at 1 kHz.""" freq = np.logspace(1, 5, 300) mag_db = -60 * np.ones_like(freq) peak_idx = np.argmin(np.abs(freq - 1e3)) mag_db[peak_idx] = 0.0 return freq, mag_db # --------------------------------------------------------------------------- # Timeseries # --------------------------------------------------------------------------- class TestPlotTimeseries: def test_basic(self, sine_wave): """A simple sine wave produces a valid SVG with expected elements.""" t, v = sine_wave svg = plot_timeseries(t, v) assert svg.startswith("" in svg def test_custom_title_and_labels(self, sine_wave): """Custom title and ylabel should appear in the SVG output.""" t, v = sine_wave svg = plot_timeseries(t, v, title="My Signal", ylabel="Current (A)") assert "My Signal" in svg assert "Current (A)" in svg def test_svg_dimensions(self, sine_wave): """The width/height attributes should match the requested size.""" t, v = sine_wave svg = plot_timeseries(t, v, width=1024, height=768) assert 'width="1024"' in svg assert 'height="768"' in svg # --------------------------------------------------------------------------- # Bode # --------------------------------------------------------------------------- class TestPlotBode: def test_magnitude_only(self, bode_data): """Bode plot without phase produces a valid SVG with one trace.""" freq, mag_db, _ = bode_data svg = plot_bode(freq, mag_db) assert svg.startswith(" elements (mag + phase).""" freq, mag_db, phase_deg = bode_data svg = plot_bode(freq, mag_db, phase_deg) assert svg.startswith("= 2 # Phase subplot label assert "Phase (deg)" in svg def test_log_axis_ticks(self, bode_data): """Log frequency axis should contain tick labels at powers of 10.""" freq, mag_db, _ = bode_data svg = plot_bode(freq, mag_db) # Expect at least some frequency labels like "100", "1k", "10k", "100k" found = sum(1 for lbl in ("100", "1k", "10k", "100k") if lbl in svg) assert found >= 2, f"Expected log tick labels in SVG; found {found}" # --------------------------------------------------------------------------- # Spectrum # --------------------------------------------------------------------------- class TestPlotSpectrum: def test_basic(self, spectrum_data): """A simple spectrum produces a valid SVG.""" freq, mag_db = spectrum_data svg = plot_spectrum(freq, mag_db) assert svg.startswith("= 3 assert ticks[0] <= 0 assert ticks[-1] >= 10 def test_equal_values(self): """When vmin == vmax, return a single-element list.""" ticks = _nice_ticks(5, 5) assert ticks == [5] def test_negative_range(self): ticks = _nice_ticks(-100, -20, n_ticks=5) assert ticks[0] <= -100 assert ticks[-1] >= -20 def test_small_range(self): ticks = _nice_ticks(0.001, 0.005, n_ticks=5) assert all(0 <= t <= 0.01 for t in ticks) class TestFormatFreq: def test_hz(self): assert _format_freq(1) == "1" assert _format_freq(10) == "10" assert _format_freq(100) == "100" def test_khz(self): assert _format_freq(1000) == "1k" assert _format_freq(10000) == "10k" assert _format_freq(100000) == "100k" def test_mhz(self): assert _format_freq(1e6) == "1M" assert _format_freq(10e6) == "10M" def test_ghz(self): assert _format_freq(1e9) == "1G" def test_zero(self): assert _format_freq(0) == "0" class TestSvgDimensions: def test_timeseries_dimensions(self): t = np.linspace(0, 1, 100) v = np.sin(t) svg = plot_timeseries(t, v, width=640, height=480) assert 'width="640"' in svg assert 'height="480"' in svg def test_bode_dimensions(self): freq = np.logspace(1, 5, 50) mag = np.zeros(50) svg = plot_bode(freq, mag, width=900, height=600) assert 'width="900"' in svg assert 'height="600"' in svg def test_spectrum_dimensions(self): freq = np.logspace(1, 5, 50) mag = np.zeros(50) svg = plot_spectrum(freq, mag, width=1000, height=500) assert 'width="1000"' in svg assert 'height="500"' in svg # --------------------------------------------------------------------------- # Axis range overrides # --------------------------------------------------------------------------- class TestAxisRangeOverrides: def test_timeseries_custom_axis_range(self, sine_wave): """Explicit x/y ranges produce valid SVG without crashing.""" t, v = sine_wave svg = plot_timeseries( t, v, x_min=0.002, x_max=0.005, y_min=-0.5, y_max=0.5, ) assert svg.startswith("" in svg def test_bode_custom_freq_range(self, bode_data): """Bode plot with explicit frequency range includes ticks in range.""" freq, mag_db, phase_deg = bode_data svg = plot_bode( freq, mag_db, phase_deg, x_min=100.0, x_max=100_000.0, y_min=-40.0, y_max=0.0, ) assert svg.startswith("= 1, f"Expected freq tick labels in range; found {found}" def test_spectrum_custom_range(self, spectrum_data): """Spectrum with explicit ranges produces valid SVG.""" freq, mag_db = spectrum_data svg = plot_spectrum( freq, mag_db, x_min=100.0, x_max=50_000.0, y_min=-80.0, y_max=10.0, ) assert svg.startswith(" elements.""" t, traces = two_traces svg = plot_timeseries_multi(t, traces) assert svg.startswith("" in svg assert svg.count("= 2 def test_timeseries_multi_legend(self, two_traces): """Legend should contain both trace names.""" t, traces = two_traces svg = plot_timeseries_multi(t, traces) assert "V(sig1)" in svg assert "V(sig2)" in svg def test_timeseries_multi_unified_yrange(self, two_traces): """Y axis should cover both traces — sig2 peaks at ~2.5, sig1 at ~1.0.""" t, traces = two_traces svg = plot_timeseries_multi(t, traces) # The SVG should not clip — both traces' paths should be present path_count = svg.count("= 2 def test_timeseries_multi_colors(self, two_traces): """Paths should use distinct stroke colors.""" t, traces = two_traces svg = plot_timeseries_multi(t, traces) # First trace is blue (#2563eb), second is red (#dc2626) assert "#2563eb" in svg assert "#dc2626" in svg def test_timeseries_multi_single_trace(self): """Single trace through multi function produces valid SVG with one path.""" t = np.linspace(0, 0.001, 100) v = np.sin(2 * np.pi * 5000 * t) svg = plot_timeseries_multi(t, [("V(out)", v)]) assert svg.startswith("