Skip to content

Commit ac02f08

Browse files
endolithopencode
andcommitted
Expand tests for scripts, THD, and WAV loading
Add subprocess PYTHONPATH injection so measure_freq and wave_analyzer CLI tests find waveform_analysis without an editable install. Add direct script tests via importlib and runpy (including fake tkinter for Windows launchers), wave_analyzer analyze and error paths, THDN A-weighting, THD verbose logging, and scipy int32 PCM scaling in load(). Co-authored-by: opencode <opencode@anomalyco.ai>
1 parent 6bfa59b commit ac02f08

6 files changed

Lines changed: 333 additions & 3 deletions

File tree

tests/subprocess_helpers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""
2+
Helpers for subprocess-based script tests.
3+
4+
Scripts import ``waveform_analysis`` as a top-level package. Running them with
5+
``sys.executable script.py`` only finds that package if it is installed
6+
(``pip install -e .``) or if ``PYTHONPATH`` includes the repository root. CI
7+
installs the package; setting ``PYTHONPATH`` here keeps local ``pytest`` runs
8+
working the same way without an editable install.
9+
"""
10+
11+
import os
12+
13+
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
14+
REPO_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, '..'))
15+
16+
17+
def env_with_repo_on_pythonpath():
18+
env = os.environ.copy()
19+
key = 'PYTHONPATH'
20+
prefix = REPO_ROOT
21+
if env.get(key):
22+
env[key] = prefix + os.pathsep + env[key]
23+
else:
24+
env[key] = prefix
25+
return env

tests/test_common.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
2+
import tempfile
23

34
import numpy as np
45
import pytest
56

67
from waveform_analysis._common import (analyze_channels, dB, find, load,
7-
parabolic, parabolic_polyfit, rms_flat)
8+
parabolic, parabolic_polyfit, rms_flat,
9+
wav_loader)
810

911
# Get the test files directory
1012
tests_dir = os.path.dirname(__file__)
@@ -67,6 +69,29 @@ def test_load_handles_invalid_files(self):
6769
# already handled. The error case is kept as a safeguard and marked
6870
# with "pragma: no cover"
6971

72+
@pytest.mark.skipif(
73+
wav_loader != 'scipy.io.wavfile',
74+
reason='32-bit PCM scaling is exercised in the SciPy wavfile path',
75+
)
76+
def test_load_scipy_int32_pcm_scaling(self):
77+
from scipy.io import wavfile
78+
79+
sr = 8000
80+
n = 4000
81+
t = np.linspace(0, n / sr, n, endpoint=False)
82+
pcm = (np.sin(2 * np.pi * 440 * t) * (2 ** 31 - 1)).astype(np.int32)
83+
fd, path = tempfile.mkstemp(suffix='.wav')
84+
os.close(fd)
85+
try:
86+
wavfile.write(path, sr, pcm)
87+
soundfile = load(path)
88+
finally:
89+
os.unlink(path)
90+
assert soundfile['fs'] == sr
91+
assert soundfile['channels'] == 1
92+
assert soundfile['signal'].dtype == np.float64
93+
assert np.max(np.abs(soundfile['signal'])) <= 1.0
94+
7095

7196
class TestAnalyzeChannels:
7297
def test_analyze_channels_processes_all_channels(self):

tests/test_measure_freq.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import pytest
77

8+
from subprocess_helpers import env_with_repo_on_pythonpath
9+
810
# Get the base directory for waveform-analysis project
911
tests_dir = os.path.dirname(__file__)
1012
script_path = os.path.join(tests_dir, '..', 'scripts', 'measure_freq.py')
@@ -32,7 +34,8 @@ def run_measure_freq(filename="", extra_args=None):
3234
result = subprocess.run(
3335
args,
3436
capture_output=True,
35-
text=True
37+
text=True,
38+
env=env_with_repo_on_pythonpath(),
3639
)
3740
return result
3841

tests/test_scripts_invoked.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""
2+
Exercise ``scripts/`` modules by import and ``runpy`` so they are covered under
3+
``--cov=scripts`` without relying on coverage in child processes.
4+
"""
5+
6+
import importlib.util
7+
import os
8+
import runpy
9+
import sys
10+
import tempfile
11+
import types
12+
from unittest.mock import MagicMock, patch
13+
14+
import numpy as np
15+
import pytest
16+
17+
from subprocess_helpers import REPO_ROOT
18+
19+
20+
def _load_script(unique_name, relpath):
21+
path = os.path.join(REPO_ROOT, relpath)
22+
spec = importlib.util.spec_from_file_location(unique_name, path)
23+
mod = importlib.util.module_from_spec(spec)
24+
spec.loader.exec_module(mod)
25+
return mod
26+
27+
28+
tests_dir = os.path.dirname(__file__)
29+
test_wav = os.path.join(tests_dir, 'test_files', 'test-44100Hz-le-1ch-4bytes.wav')
30+
31+
32+
class TestMeasureFreqScript:
33+
def test_freq_wrapper_prints(self, capsys):
34+
mod = _load_script('measure_freq_script', 'scripts/measure_freq.py')
35+
fs = 48000
36+
t = np.linspace(0, 1, num=fs, endpoint=False)
37+
sig = np.sin(2 * np.pi * 1000 * t)
38+
mod.freq_wrapper(sig, fs)
39+
assert 'Hz' in capsys.readouterr().out
40+
41+
def test_main_no_files_exits(self):
42+
script = os.path.join(REPO_ROOT, 'scripts', 'measure_freq.py')
43+
with patch('sys.stdout.isatty', return_value=False):
44+
with patch('sys.argv', ['measure_freq.py']):
45+
with pytest.raises(SystemExit):
46+
runpy.run_path(script, run_name='__main__')
47+
48+
49+
class TestThdAnalyzerScript:
50+
def test_thd_wrapper_prints(self, capsys):
51+
mod = _load_script('thd_analyzer_script', 'scripts/thd_analyzer.py')
52+
fs = 48000
53+
t = np.linspace(0, 1, num=fs, endpoint=False)
54+
sig = np.sin(2 * np.pi * 1000 * t)
55+
mod.thd_wrapper(sig, fs)
56+
out = capsys.readouterr().out
57+
assert 'THD+N' in out and 'THD(F)' in out
58+
59+
def test_thd_analyzer_empty_exits(self):
60+
mod = _load_script('thd_analyzer_script2', 'scripts/thd_analyzer.py')
61+
with pytest.raises(SystemExit):
62+
mod.thd_analyzer([])
63+
64+
def test_thd_analyzer_with_wav(self, capsys):
65+
mod = _load_script('thd_analyzer_script3', 'scripts/thd_analyzer.py')
66+
mod.thd_analyzer([test_wav])
67+
assert 'THD+N' in capsys.readouterr().out
68+
69+
def test_thd_analyzer_ioerror_per_file(self, capsys):
70+
mod = _load_script('thd_analyzer_io', 'scripts/thd_analyzer.py')
71+
with patch.object(mod, 'analyze_channels', side_effect=IOError()):
72+
mod.thd_analyzer([test_wav])
73+
assert "Couldn't analyze" in capsys.readouterr().out
74+
75+
76+
class TestWaveAnalyzerScript:
77+
def test_wave_analyzer_nonexistent_file(self):
78+
with patch('importlib.util.find_spec', return_value=None):
79+
mod = _load_script('wave_analyzer_nf', 'scripts/wave_analyzer.py')
80+
with pytest.raises(SystemExit, match='File not found'):
81+
mod.wave_analyzer(['/nonexistent/path/does-not-exist.wav'],
82+
gui=False)
83+
84+
def test_wave_analyzer_invalid_wav(self):
85+
bad = os.path.join(
86+
tests_dir, 'test_files',
87+
'test-44100Hz-le-1ch-4bytes-incomplete-chunk.wav')
88+
with patch('importlib.util.find_spec', return_value=None):
89+
mod = _load_script('wave_analyzer_inv', 'scripts/wave_analyzer.py')
90+
with pytest.raises(SystemExit) as exc:
91+
mod.wave_analyzer([bad], gui=False)
92+
msg = str(exc.value)
93+
assert 'Invalid audio file' in msg or 'I/O error' in msg
94+
95+
def test_analyze_stereo_different_channels(self, capsys):
96+
stereo = os.path.join(
97+
tests_dir, 'test_files', 'test-8000Hz-le-2ch-1byteu.wav')
98+
with patch('importlib.util.find_spec', return_value=None):
99+
mod = _load_script('wave_analyzer_st', 'scripts/wave_analyzer.py')
100+
mod.analyze(stereo, gui=False)
101+
out = capsys.readouterr().out
102+
assert 'Left channel' in out and 'Right channel' in out
103+
104+
def test_analyze_stereo_identical_channels(self, capsys):
105+
stereo = os.path.join(
106+
tests_dir, 'test_files', 'test-44100Hz-2ch-32bit-float-be.wav')
107+
with patch('importlib.util.find_spec', return_value=None):
108+
mod = _load_script('wave_analyzer_stid', 'scripts/wave_analyzer.py')
109+
mod.analyze(stereo, gui=False)
110+
out = capsys.readouterr().out
111+
assert 'identical' in out.lower()
112+
113+
@pytest.mark.filterwarnings('ignore::RuntimeWarning')
114+
def test_analyze_multichannel(self, capsys):
115+
quad = os.path.join(
116+
tests_dir, 'test_files', 'test-8000Hz-le-4ch-9S-12bit.wav')
117+
with patch('importlib.util.find_spec', return_value=None):
118+
mod = _load_script('wave_analyzer_4ch', 'scripts/wave_analyzer.py')
119+
mod.analyze(quad, gui=False)
120+
out = capsys.readouterr().out
121+
assert 'Channel 1' in out and 'Channel 4' in out
122+
123+
def test_wave_analyzer_no_files(self):
124+
with patch('importlib.util.find_spec', return_value=None):
125+
mod = _load_script('wave_analyzer_nof', 'scripts/wave_analyzer.py')
126+
with pytest.raises(SystemExit, match='at least one file'):
127+
mod.wave_analyzer([], gui=False)
128+
129+
def test_wave_analyzer_unexpected_error(self):
130+
with patch('importlib.util.find_spec', return_value=None):
131+
mod = _load_script('wave_analyzer_unexp', 'scripts/wave_analyzer.py')
132+
with patch.object(mod, 'analyze', side_effect=RuntimeError('boom')):
133+
with pytest.raises(SystemExit, match='Unexpected error'):
134+
mod.wave_analyzer([test_wav], gui=False)
135+
136+
def test_wave_analyzer_io_error(self):
137+
with patch('importlib.util.find_spec', return_value=None):
138+
mod = _load_script('wave_analyzer_io', 'scripts/wave_analyzer.py')
139+
with patch.object(mod, 'load', side_effect=IOError('read fail')):
140+
with pytest.raises(SystemExit, match='I/O error'):
141+
mod.wave_analyzer([test_wav], gui=False)
142+
143+
def test_analyze_seconds_length_line(self, capsys):
144+
long_wav = os.path.join(
145+
tests_dir, 'test_files', 'test-1234Hz-le-1ch-10S-20bit-extra.wav')
146+
with patch('importlib.util.find_spec', return_value=None):
147+
mod = _load_script('wave_analyzer_len', 'scripts/wave_analyzer.py')
148+
mod.analyze(long_wav, gui=False)
149+
assert 'seconds' in capsys.readouterr().out
150+
151+
def test_display_with_easygui(self):
152+
eg = MagicMock()
153+
eg.codebox = MagicMock()
154+
with patch.dict(sys.modules, {'easygui': eg}):
155+
with patch('importlib.util.find_spec', return_value=MagicMock()):
156+
mod = _load_script('wave_analyzer_eg', 'scripts/wave_analyzer.py')
157+
mod.display('Hdr', ['r1'], gui=True)
158+
eg.codebox.assert_called_once()
159+
160+
def test_wave_analyzer_one_file(self, capsys):
161+
with patch('importlib.util.find_spec', return_value=None):
162+
mod = _load_script('wave_analyzer_cli', 'scripts/wave_analyzer.py')
163+
mod.wave_analyzer([test_wav], gui=False)
164+
out = capsys.readouterr().out
165+
assert '44100' in out
166+
167+
def test_display_gui_without_easygui(self, capsys):
168+
with patch('importlib.util.find_spec', return_value=None):
169+
mod = _load_script('wave_analyzer_disp', 'scripts/wave_analyzer.py')
170+
mod.display('Header', ['line1', 'line2'], gui=True)
171+
assert 'No EasyGUI' in capsys.readouterr().out
172+
173+
def test_analyze_subsecond_shows_milliseconds(self, capsys):
174+
from scipy.io import wavfile
175+
176+
with patch('importlib.util.find_spec', return_value=None):
177+
mod = _load_script('wave_analyzer_sub', 'scripts/wave_analyzer.py')
178+
sr = 44100
179+
n = 200
180+
pcm = (0.1 * np.sin(2 * np.pi * 440 * np.linspace(0, n / sr, n,
181+
endpoint=False)))
182+
pcm_i16 = (pcm * (2 ** 15 - 1)).astype(np.int16)
183+
fd, path = tempfile.mkstemp(suffix='.wav')
184+
os.close(fd)
185+
try:
186+
wavfile.write(path, sr, pcm_i16)
187+
mod.analyze(path, gui=False)
188+
finally:
189+
os.unlink(path)
190+
out = capsys.readouterr().out
191+
assert 'milliseconds' in out
192+
193+
def test_histogram_uses_matplotlib_when_available(self):
194+
pytest.importorskip('matplotlib')
195+
import matplotlib
196+
matplotlib.use('Agg')
197+
import matplotlib.pyplot as plt
198+
199+
with patch('importlib.util.find_spec', return_value=None):
200+
mod = _load_script('wave_analyzer_hist', 'scripts/wave_analyzer.py')
201+
with patch.object(plt, 'hist'), patch.object(plt, 'show'):
202+
mod.histogram(np.array([0.0, 1.0, 0.5, -0.25]))
203+
204+
205+
class TestScriptLaunchers:
206+
scripts_dir = os.path.join(REPO_ROOT, 'scripts')
207+
208+
@staticmethod
209+
def _fake_tkinter_modules():
210+
tk = types.ModuleType('tkinter')
211+
msg = types.ModuleType('tkinter.messagebox')
212+
msg.showerror = MagicMock(return_value='ok')
213+
tk.messagebox = msg
214+
tk.Tk = lambda *a, **k: MagicMock(withdraw=MagicMock())
215+
return tk, msg
216+
217+
def test_thd_analyzer_launcher_empty_argv(self):
218+
tk, msg = self._fake_tkinter_modules()
219+
script = os.path.join(REPO_ROOT, 'scripts', 'thd_analyzer_launcher.py')
220+
with patch.dict(sys.modules, {'tkinter': tk, 'tkinter.messagebox': msg},
221+
clear=False):
222+
with patch('sys.argv', ['thd_analyzer_launcher.py']):
223+
saved = sys.path[:]
224+
sys.path.insert(0, self.scripts_dir)
225+
try:
226+
with pytest.raises(SystemExit):
227+
runpy.run_path(script, run_name='__main__')
228+
finally:
229+
sys.path[:] = saved
230+
msg.showerror.assert_called()
231+
232+
def test_wave_analyzer_launcher_missing_file(self):
233+
tk, msg = self._fake_tkinter_modules()
234+
script = os.path.join(REPO_ROOT, 'scripts', 'wave_analyzer_launcher.py')
235+
with patch.dict(sys.modules, {'tkinter': tk, 'tkinter.messagebox': msg},
236+
clear=False):
237+
with patch('sys.argv', ['wave_analyzer_launcher.py', 'missing.wav']):
238+
saved = sys.path[:]
239+
sys.path.insert(0, self.scripts_dir)
240+
try:
241+
with pytest.raises(SystemExit):
242+
runpy.run_path(script, run_name='__main__')
243+
finally:
244+
sys.path[:] = saved
245+
msg.showerror.assert_called()

tests/test_thd.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import io
12
import os
3+
from contextlib import redirect_stdout
24
from glob import glob
35

46
import numpy as np
@@ -149,6 +151,30 @@ def test_freq_parameter(self):
149151
explicit_thd = THD(signal, fs, freq=f)
150152
assert explicit_thd == pytest.approx(auto_thd)
151153

154+
def test_thdn_a_weighting_on_residual(self):
155+
fs = 48000
156+
f = 1000
157+
t = np.linspace(0, 1, fs, endpoint=False)
158+
signal = sine_wave(f, fs) + 0.75 * sine_wave(2 * f, fs)
159+
unweighted = THDN(signal, fs, weight=None)
160+
a_weighted = THDN(signal, fs, weight='A')
161+
assert unweighted > 0
162+
assert a_weighted > 0
163+
assert a_weighted != pytest.approx(unweighted)
164+
165+
def test_thd_verbose_output(self):
166+
fs = 100000
167+
f = 1000
168+
signal = sine_wave(f, fs) + 0.75 * sine_wave(2 * f, fs)
169+
buf = io.StringIO()
170+
with redirect_stdout(buf):
171+
result = THD(signal, fs, verbose=True)
172+
out = buf.getvalue()
173+
assert 'Frequency:' in out
174+
assert 'Harmonic 2' in out
175+
assert 'THD:' in out
176+
assert result == pytest.approx(0.75)
177+
152178

153179
if __name__ == '__main__':
154180
pytest.main([__file__, "--capture=sys"])

tests/test_wave_analyzer.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
from subprocess_helpers import env_with_repo_on_pythonpath
89
from waveform_analysis._common import wav_loader
910

1011
# Get the base directory for waveform-analysis project
@@ -18,7 +19,12 @@ def run_wave_analyzer(filename=None, extra_args=[]):
1819
if filename:
1920
cmd.append(os.path.join(test_files_dir, filename))
2021
cmd.extend(extra_args)
21-
return subprocess.run(cmd, capture_output=True, text=True)
22+
return subprocess.run(
23+
cmd,
24+
capture_output=True,
25+
text=True,
26+
env=env_with_repo_on_pythonpath(),
27+
)
2228

2329

2430
class TestWaveAnalyzer:

0 commit comments

Comments
 (0)