"""Comprehensive test suite for the entire pipeline. Covers: - All 12 appliance types - Edge cases (empty, garbage, short, loud audio) - Integration tests (features -> rules -> prompt -> validate) - Rule engine grounding verification - JSON guard validation """ import os import sys import json import tempfile import numpy as np import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from audio_analyzer import extract_features, AudioFeatures # noqa: E402 from fault_rules import rank_candidates, RULES, GENERIC_FALLBACK # noqa: E402 from feature_prompt import build_diagnosis_prompt, SYSTEM_PROMPT # noqa: E402 from json_guard import validate, DiagnosisResult # noqa: E402 ASSETS = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") def _have(name): return os.path.exists(os.path.join(ASSETS, name)) def _write_wav(samples, sr=22050): """Write samples to a temp WAV file and return the path.""" import soundfile as sf path = tempfile.mktemp(suffix=".wav") sf.write(path, samples, sr) return path def _make_tone(freq, duration, sr=22050, amplitude=0.5): """Generate a simple sine tone.""" t = np.linspace(0, duration, int(sr * duration), endpoint=False) return (amplitude * np.sin(2 * np.pi * freq * t)).astype(np.float32) def _make_clicks(interval_s, count, sr=22050, amplitude=0.5): """Generate regular clicks at specified intervals. Each click is a short burst of broadband noise (30ms) with a sharp attack, making it detectable by onset detection with delta=0.3. """ total_samples = int(sr * interval_s * count * 1.5) samples = np.zeros(total_samples, dtype=np.float32) click_len = int(0.03 * sr) # 30ms click for i in range(count): start = int(i * interval_s * sr) if start + click_len < total_samples: # Sharp-attack noise burst with exponential decay click = amplitude * np.random.randn(click_len).astype(np.float32) decay = np.exp(-np.linspace(0, 5, click_len)).astype(np.float32) samples[start:start+click_len] = click * decay return samples def _make_noise(duration, sr=22050, amplitude=0.1): """Generate random noise.""" return (amplitude * np.random.randn(int(sr * duration))).astype(np.float32) # ============================================================================= # SECTION 1: Original smoke tests (kept for backward compatibility) # ============================================================================= @pytest.mark.skipif(not _have("sample_washer_bearing.wav"), reason="run assets/generate_samples.py first") def test_bearing_sample_detects_pattern(): f = extract_features(os.path.join(ASSETS, "sample_washer_bearing.wav")) assert f.has_regular_pattern, "rhythmic clicks should be detected" cands = rank_candidates(f, "Washing machine") assert any("bearing" in c.name.lower() for c in cands), \ f"bearing should be a candidate, got {[c.name for c in cands]}" @pytest.mark.skipif(not _have("sample_washer_good.wav"), reason="run assets/generate_samples.py first") def test_good_sample_is_calm(): f = extract_features(os.path.join(ASSETS, "sample_washer_good.wav")) assert not f.has_regular_pattern assert f.anomaly_score < 0.6 def test_empty_audio_does_not_crash(tmp_path): import soundfile as sf p = tmp_path / "silence.wav" sf.write(p, np.zeros(1600, dtype="float32"), 16000) f = extract_features(str(p)) cands = rank_candidates(f, "Washing machine") assert cands # always returns at least 'Inconclusive' # ============================================================================= # SECTION 2: All 12 appliance rule coverage tests # ============================================================================= class TestApplianceRules: """Verify every appliance in APPLIANCES has rules and returns candidates.""" def test_all_appliances_have_rules(self): """Every appliance should have its own rule table (not just generic fallback).""" expected = [ "Washing machine", "Tumble dryer", "Refrigerator/Freezer", "Electric fan", "Air conditioner", "Vacuum cleaner", "Dishwasher", "Microwave", "Electric motor (generic)", "Car engine", "Bicycle (chain/gears)", "Power drill", ] for appliance in expected: assert appliance in RULES, f"Missing rules for '{appliance}'" assert len(RULES[appliance]) >= 2, \ f"'{appliance}' should have at least 2 rules, has {len(RULES[appliance])}" def test_washing_machine_bearing(self): """Washer bearing: regular pattern, bright spectrum.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.03, zero_crossing_rate=0.08, spectral_centroid_hz=2200, spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, dominant_frequency_hz=180.0, harmonic_ratio=0.65, onset_rate_per_sec=3.5, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, ) cands = rank_candidates(f, "Washing machine") assert any("bearing" in c.name.lower() for c in cands) assert cands[0].urgency in ("HIGH", "CRITICAL") def test_washing_machine_belt_slip(self): """Washer belt slip: high freq harmonic tone.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.01, zero_crossing_rate=0.06, spectral_centroid_hz=2800, spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, dominant_frequency_hz=2200.0, harmonic_ratio=0.72, onset_rate_per_sec=0.5, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Washing machine") assert any("belt" in c.name.lower() for c in cands) def test_washing_machine_load_imbalance(self): """Washer load imbalance: high variance, no pattern.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.05, zero_crossing_rate=0.04, spectral_centroid_hz=600, spectral_bandwidth_hz=2000, spectral_rolloff_hz=3000, dominant_frequency_hz=80.0, harmonic_ratio=0.3, onset_rate_per_sec=1.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-15.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Washing machine") assert any("imbalance" in c.name.lower() for c in cands) def test_washing_machine_foreign_object(self): """Washer foreign object: irregular harsh knocks.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.02, zero_crossing_rate=0.15, spectral_centroid_hz=2000, spectral_bandwidth_hz=2500, spectral_rolloff_hz=5000, dominant_frequency_hz=100.0, harmonic_ratio=0.2, onset_rate_per_sec=6.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Washing machine") assert any("object" in c.name.lower() for c in cands) def test_electric_fan_blade_imbalance(self): """Fan blade imbalance: low freq, amplitude modulation.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.015, zero_crossing_rate=0.05, spectral_centroid_hz=500, spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, dominant_frequency_hz=60.0, harmonic_ratio=0.5, onset_rate_per_sec=0.8, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Electric fan") assert any("imbalance" in c.name.lower() for c in cands) def test_electric_fan_bearing_failure(self): """Fan motor bearing: bright, harsh.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.008, zero_crossing_rate=0.18, spectral_centroid_hz=3000, spectral_bandwidth_hz=2500, spectral_rolloff_hz=6000, dominant_frequency_hz=2500.0, harmonic_ratio=0.4, onset_rate_per_sec=1.2, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-20.0, anomaly_score=0.65, ) cands = rank_candidates(f, "Electric fan") assert any("bearing" in c.name.lower() for c in cands) def test_electric_fan_blade_strike(self): """Fan blade striking housing: fast regular ticking.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.02, zero_crossing_rate=0.1, spectral_centroid_hz=1500, spectral_bandwidth_hz=1500, spectral_rolloff_hz=3500, dominant_frequency_hz=100.0, harmonic_ratio=0.5, onset_rate_per_sec=12.0, has_regular_pattern=True, pattern_interval_ms=50.0, peak_db=-20.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Electric fan") assert any("strike" in c.name.lower() or "housing" in c.name.lower() for c in cands) def test_car_engine_rod_knock(self): """Car engine rod knock: rhythmic, bright, loud.""" f = AudioFeatures( duration_s=8.0, rms_db=-20.0, rms_variance=0.04, zero_crossing_rate=0.12, spectral_centroid_hz=1800, spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, dominant_frequency_hz=200.0, harmonic_ratio=0.7, onset_rate_per_sec=5.0, has_regular_pattern=True, pattern_interval_ms=80.0, peak_db=-12.0, anomaly_score=0.85, ) cands = rank_candidates(f, "Car engine") assert any("rod" in c.name.lower() or "knock" in c.name.lower() for c in cands) assert cands[0].urgency == "CRITICAL" def test_car_engine_belt_squeal(self): """Car engine belt squeal: high freq harmonic.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.005, zero_crossing_rate=0.06, spectral_centroid_hz=2500, spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, dominant_frequency_hz=2500.0, harmonic_ratio=0.6, onset_rate_per_sec=0.3, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.5, ) cands = rank_candidates(f, "Car engine") assert any("belt" in c.name.lower() or "squeal" in c.name.lower() for c in cands) def test_tumble_dryer_roller_wear(self): """Tumble dryer drum roller wear: rhythmic thump.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.025, zero_crossing_rate=0.07, spectral_centroid_hz=1200, spectral_bandwidth_hz=1500, spectral_rolloff_hz=3500, dominant_frequency_hz=100.0, harmonic_ratio=0.55, onset_rate_per_sec=3.0, has_regular_pattern=True, pattern_interval_ms=180.0, peak_db=-20.0, anomaly_score=0.65, ) cands = rank_candidates(f, "Tumble dryer") assert any("roller" in c.name.lower() or "drum" in c.name.lower() for c in cands) def test_tumble_dryer_belt_slip(self): """Tumble dryer belt slip: high squeal.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.008, zero_crossing_rate=0.06, spectral_centroid_hz=2200, spectral_bandwidth_hz=1000, spectral_rolloff_hz=4500, dominant_frequency_hz=2000.0, harmonic_ratio=0.6, onset_rate_per_sec=0.3, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Tumble dryer") assert any("belt" in c.name.lower() for c in cands) def test_tumble_dryer_foreign_object(self): """Tumble dryer foreign object: irregular rattle.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.015, zero_crossing_rate=0.1, spectral_centroid_hz=1800, spectral_bandwidth_hz=2000, spectral_rolloff_hz=4000, dominant_frequency_hz=100.0, harmonic_ratio=0.3, onset_rate_per_sec=7.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-20.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Tumble dryer") assert any("object" in c.name.lower() or "coin" in c.name.lower() for c in cands) def test_refrigerator_compressor_bearing(self): """Fridge compressor bearing: fast regular click.""" f = AudioFeatures( duration_s=8.0, rms_db=-32.0, rms_variance=0.01, zero_crossing_rate=0.06, spectral_centroid_hz=1800, spectral_bandwidth_hz=1200, spectral_rolloff_hz=3500, dominant_frequency_hz=60.0, harmonic_ratio=0.7, onset_rate_per_sec=8.0, has_regular_pattern=True, pattern_interval_ms=60.0, peak_db=-25.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Refrigerator/Freezer") assert any("compressor" in c.name.lower() for c in cands) def test_refrigerator_evaporator_fan(self): """Fridge evaporator fan: steady drone.""" f = AudioFeatures( duration_s=8.0, rms_db=-35.0, rms_variance=0.008, zero_crossing_rate=0.05, spectral_centroid_hz=2200, spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, dominant_frequency_hz=500.0, harmonic_ratio=0.6, onset_rate_per_sec=0.5, has_regular_pattern=True, pattern_interval_ms=100.0, peak_db=-28.0, anomaly_score=0.5, ) cands = rank_candidates(f, "Refrigerator/Freezer") assert any("fan" in c.name.lower() or "evaporator" in c.name.lower() for c in cands) def test_refrigerator_condenser_grind(self): """Fridge condenser fan grind: broadband harsh.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.01, zero_crossing_rate=0.18, spectral_centroid_hz=2000, spectral_bandwidth_hz=3000, spectral_rolloff_hz=5000, dominant_frequency_hz=100.0, harmonic_ratio=0.3, onset_rate_per_sec=2.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Refrigerator/Freezer") assert any("condenser" in c.name.lower() or "grind" in c.name.lower() for c in cands) def test_air_conditioner_compressor_failure(self): """AC compressor failure: CRITICAL, loud, rhythmic.""" f = AudioFeatures( duration_s=8.0, rms_db=-18.0, rms_variance=0.05, zero_crossing_rate=0.15, spectral_centroid_hz=2200, spectral_bandwidth_hz=2500, spectral_rolloff_hz=5500, dominant_frequency_hz=200.0, harmonic_ratio=0.65, onset_rate_per_sec=6.0, has_regular_pattern=True, pattern_interval_ms=100.0, peak_db=-10.0, anomaly_score=0.9, ) cands = rank_candidates(f, "Air conditioner") assert any("compressor" in c.name.lower() for c in cands) assert cands[0].urgency == "CRITICAL" def test_air_conditioner_fan_blade(self): """AC fan blade damage: low thwack.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.02, zero_crossing_rate=0.06, spectral_centroid_hz=800, spectral_bandwidth_hz=1200, spectral_rolloff_hz=2500, dominant_frequency_hz=80.0, harmonic_ratio=0.5, onset_rate_per_sec=4.0, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-22.0, anomaly_score=0.5, ) cands = rank_candidates(f, "Air conditioner") assert any("blade" in c.name.lower() or "fan" in c.name.lower() for c in cands) def test_air_conditioner_refrigerant_leak(self): """AC refrigerant leak: bright hiss, no pattern.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.005, zero_crossing_rate=0.15, spectral_centroid_hz=3500, spectral_bandwidth_hz=2000, spectral_rolloff_hz=6000, dominant_frequency_hz=3000.0, harmonic_ratio=0.2, onset_rate_per_sec=0.2, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-25.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Air conditioner") assert any("refrigerant" in c.name.lower() or "leak" in c.name.lower() for c in cands) def test_vacuum_brush_roll_bearing(self): """Vacuum brush roll bearing: fast regular click.""" f = AudioFeatures( duration_s=8.0, rms_db=-22.0, rms_variance=0.015, zero_crossing_rate=0.12, spectral_centroid_hz=2500, spectral_bandwidth_hz=2000, spectral_rolloff_hz=5000, dominant_frequency_hz=300.0, harmonic_ratio=0.5, onset_rate_per_sec=10.0, has_regular_pattern=True, pattern_interval_ms=50.0, peak_db=-15.0, anomaly_score=0.7, ) cands = rank_candidates(f, "Vacuum cleaner") assert any("brush" in c.name.lower() or "roll" in c.name.lower() for c in cands) def test_vacuum_motor_whine(self): """Vacuum motor bearing whine: high harmonic.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.008, zero_crossing_rate=0.08, spectral_centroid_hz=2500, spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, dominant_frequency_hz=2500.0, harmonic_ratio=0.6, onset_rate_per_sec=0.3, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Vacuum cleaner") assert any("whine" in c.name.lower() or "bearing" in c.name.lower() for c in cands) def test_vacuum_blockage(self): """Vacuum airway blockage: loud broadband rush.""" f = AudioFeatures( duration_s=8.0, rms_db=-20.0, rms_variance=0.01, zero_crossing_rate=0.1, spectral_centroid_hz=2800, spectral_bandwidth_hz=2500, spectral_rolloff_hz=5500, dominant_frequency_hz=200.0, harmonic_ratio=0.3, onset_rate_per_sec=0.5, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-12.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Vacuum cleaner") assert any("block" in c.name.lower() or "airway" in c.name.lower() for c in cands) def test_dishwasher_pump_bearing(self): """Dishwasher wash pump bearing: rhythmic rattle.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.02, zero_crossing_rate=0.08, spectral_centroid_hz=1800, spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, dominant_frequency_hz=120.0, harmonic_ratio=0.55, onset_rate_per_sec=4.0, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-20.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Dishwasher") assert any("pump" in c.name.lower() or "bearing" in c.name.lower() for c in cands) def test_dishwasher_drain_pump_cavitation(self): """Dishwasher drain pump: irregular gurgle.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.02, zero_crossing_rate=0.1, spectral_centroid_hz=2000, spectral_bandwidth_hz=3500, spectral_rolloff_hz=5500, dominant_frequency_hz=100.0, harmonic_ratio=0.25, onset_rate_per_sec=5.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Dishwasher") assert any("drain" in c.name.lower() or "cavitate" in c.name.lower() for c in cands) def test_dishwasher_spray_arm(self): """Dishwasher spray arm imbalance: slow swish.""" f = AudioFeatures( duration_s=8.0, rms_db=-35.0, rms_variance=0.015, zero_crossing_rate=0.04, spectral_centroid_hz=400, spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, dominant_frequency_hz=50.0, harmonic_ratio=0.4, onset_rate_per_sec=0.5, has_regular_pattern=True, pattern_interval_ms=500.0, peak_db=-30.0, anomaly_score=0.35, ) cands = rank_candidates(f, "Dishwasher") assert any("spray" in c.name.lower() or "arm" in c.name.lower() for c in cands) def test_microwave_turntable_motor(self): """Microwave turntable motor: low hum.""" f = AudioFeatures( duration_s=8.0, rms_db=-35.0, rms_variance=0.005, zero_crossing_rate=0.03, spectral_centroid_hz=300, spectral_bandwidth_hz=500, spectral_rolloff_hz=1000, dominant_frequency_hz=50.0, harmonic_ratio=0.7, onset_rate_per_sec=0.1, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-30.0, anomaly_score=0.2, ) cands = rank_candidates(f, "Microwave") assert any("turntable" in c.name.lower() or "motor" in c.name.lower() for c in cands) def test_microwave_magnetron_arcing(self): """Microwave magnetron arcing: harsh buzz.""" f = AudioFeatures( duration_s=8.0, rms_db=-20.0, rms_variance=0.03, zero_crossing_rate=0.25, spectral_centroid_hz=2500, spectral_bandwidth_hz=3000, spectral_rolloff_hz=6000, dominant_frequency_hz=2000.0, harmonic_ratio=0.3, onset_rate_per_sec=2.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-12.0, anomaly_score=0.8, ) cands = rank_candidates(f, "Microwave") assert any("magnetron" in c.name.lower() for c in cands) def test_microwave_cooling_fan(self): """Microwave cooling fan bearing: fast tick.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.008, zero_crossing_rate=0.06, spectral_centroid_hz=1500, spectral_bandwidth_hz=1000, spectral_rolloff_hz=3000, dominant_frequency_hz=100.0, harmonic_ratio=0.5, onset_rate_per_sec=6.0, has_regular_pattern=True, pattern_interval_ms=80.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Microwave") assert any("cooling" in c.name.lower() or "fan" in c.name.lower() for c in cands) def test_bicycle_chain_wear(self): """Bicycle chain wear: fast rhythmic click.""" f = AudioFeatures( duration_s=8.0, rms_db=-35.0, rms_variance=0.01, zero_crossing_rate=0.06, spectral_centroid_hz=1800, spectral_bandwidth_hz=1200, spectral_rolloff_hz=3500, dominant_frequency_hz=150.0, harmonic_ratio=0.5, onset_rate_per_sec=6.0, has_regular_pattern=True, pattern_interval_ms=80.0, peak_db=-28.0, anomaly_score=0.55, ) cands = rank_candidates(f, "Bicycle (chain/gears)") assert any("chain" in c.name.lower() for c in cands) def test_bicycle_wheel_bearing(self): """Bicycle wheel bearing: regular thump.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.02, zero_crossing_rate=0.08, spectral_centroid_hz=2200, spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, dominant_frequency_hz=100.0, harmonic_ratio=0.55, onset_rate_per_sec=3.0, has_regular_pattern=True, pattern_interval_ms=200.0, peak_db=-24.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Bicycle (chain/gears)") assert any("bearing" in c.name.lower() or "wheel" in c.name.lower() for c in cands) def test_bicycle_derailleur(self): """Bicycle derailleur misalignment: irregular metallic rattle.""" f = AudioFeatures( duration_s=8.0, rms_db=-32.0, rms_variance=0.015, zero_crossing_rate=0.08, spectral_centroid_hz=2000, spectral_bandwidth_hz=1800, spectral_rolloff_hz=4000, dominant_frequency_hz=100.0, harmonic_ratio=0.3, onset_rate_per_sec=4.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-26.0, anomaly_score=0.5, ) cands = rank_candidates(f, "Bicycle (chain/gears)") assert any("derailleur" in c.name.lower() for c in cands) def test_power_drill_brush_wear(self): """Power drill brush wear: harsh broadband.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.02, zero_crossing_rate=0.22, spectral_centroid_hz=2000, spectral_bandwidth_hz=3500, spectral_rolloff_hz=6000, dominant_frequency_hz=1500.0, harmonic_ratio=0.3, onset_rate_per_sec=1.5, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.65, ) cands = rank_candidates(f, "Power drill") assert any("brush" in c.name.lower() or "commutator" in c.name.lower() for c in cands) def test_power_drill_gear_grinding(self): """Power drill gear grinding: bright non-tonal.""" f = AudioFeatures( duration_s=8.0, rms_db=-26.0, rms_variance=0.025, zero_crossing_rate=0.1, spectral_centroid_hz=2200, spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, dominant_frequency_hz=100.0, harmonic_ratio=0.3, onset_rate_per_sec=4.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Power drill") assert any("gear" in c.name.lower() or "grind" in c.name.lower() for c in cands) def test_power_drill_bearing_failure(self): """Power drill bearing failure: fast regular tick.""" f = AudioFeatures( duration_s=8.0, rms_db=-24.0, rms_variance=0.02, zero_crossing_rate=0.1, spectral_centroid_hz=2500, spectral_bandwidth_hz=1800, spectral_rolloff_hz=5000, dominant_frequency_hz=200.0, harmonic_ratio=0.5, onset_rate_per_sec=8.0, has_regular_pattern=True, pattern_interval_ms=60.0, peak_db=-16.0, anomaly_score=0.7, ) cands = rank_candidates(f, "Power drill") assert any("bearing" in c.name.lower() for c in cands) def test_generic_motor_bearing(self): """Generic motor bearing failure.""" f = AudioFeatures( duration_s=8.0, rms_db=-26.0, rms_variance=0.02, zero_crossing_rate=0.1, spectral_centroid_hz=2000, spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, dominant_frequency_hz=150.0, harmonic_ratio=0.6, onset_rate_per_sec=3.0, has_regular_pattern=True, pattern_interval_ms=120.0, peak_db=-18.0, anomaly_score=0.7, ) cands = rank_candidates(f, "Electric motor (generic)") assert any("bearing" in c.name.lower() for c in cands) def test_generic_motor_hum(self): """Generic motor electrical hum.""" f = AudioFeatures( duration_s=8.0, rms_db=-35.0, rms_variance=0.005, zero_crossing_rate=0.03, spectral_centroid_hz=400, spectral_bandwidth_hz=500, spectral_rolloff_hz=1200, dominant_frequency_hz=100.0, harmonic_ratio=0.7, onset_rate_per_sec=0.1, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-30.0, anomaly_score=0.2, ) cands = rank_candidates(f, "Electric motor (generic)") assert any("hum" in c.name.lower() or "lamination" in c.name.lower() for c in cands) def test_generic_motor_brush_arcing(self): """Generic motor brush arcing.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.015, zero_crossing_rate=0.22, spectral_centroid_hz=2000, spectral_bandwidth_hz=3500, spectral_rolloff_hz=6000, dominant_frequency_hz=100.0, harmonic_ratio=0.2, onset_rate_per_sec=1.5, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.6, ) cands = rank_candidates(f, "Electric motor (generic)") assert any("brush" in c.name.lower() or "arcing" in c.name.lower() for c in cands) def test_generic_motor_squeal(self): """Generic motor high-freq squeal.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.005, zero_crossing_rate=0.08, spectral_centroid_hz=2500, spectral_bandwidth_hz=1000, spectral_rolloff_hz=5000, dominant_frequency_hz=2200.0, harmonic_ratio=0.6, onset_rate_per_sec=0.3, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.5, ) cands = rank_candidates(f, "Electric motor (generic)") assert any("squeal" in c.name.lower() or "whine" in c.name.lower() for c in cands) # ============================================================================= # SECTION 3: Edge case tests # ============================================================================= class TestEdgeCases: """Test defensive behavior on degenerate inputs.""" def test_silence_audio(self): """Pure silence should return Inconclusive.""" path = _write_wav(np.zeros(22050, dtype="float32")) # 1s silence try: f = extract_features(path) cands = rank_candidates(f, "Washing machine") assert len(cands) >= 1 assert cands[0].name == "Inconclusive" finally: os.unlink(path) def test_garbage_audio(self): """Random noise should not crash and should return candidates.""" path = _write_wav(np.random.randn(22050 * 3).astype(np.float32) * 0.01) try: f = extract_features(path) cands = rank_candidates(f, "Electric fan") assert len(cands) >= 1 finally: os.unlink(path) def test_very_short_audio(self): """50ms audio should not crash.""" samples = np.random.randn(1102).astype(np.float32) * 0.1 path = _write_wav(samples) try: f = extract_features(path) cands = rank_candidates(f, "Microwave") assert len(cands) >= 1 finally: os.unlink(path) def test_very_loud_audio(self): """Clipping audio should not crash.""" samples = np.ones(22050, dtype="float32") * 0.99 path = _write_wav(samples) try: f = extract_features(path) cands = rank_candidates(f, "Power drill") assert len(cands) >= 1 assert f.peak_db > -1.0 # should be very loud finally: os.unlink(path) def test_very_quiet_audio(self): """Near-silence audio should return Inconclusive.""" samples = np.random.randn(22050).astype(np.float32) * 0.0001 path = _write_wav(samples) try: f = extract_features(path) cands = rank_candidates(f, "Dishwasher") assert len(cands) >= 1 finally: os.unlink(path) def test_all_zeros(self): """Completely zeroed audio should not crash.""" path = _write_wav(np.zeros(44100, dtype="float32")) try: f = extract_features(path) cands = rank_candidates(f, "Air conditioner") assert len(cands) >= 1 assert cands[0].name == "Inconclusive" finally: os.unlink(path) def test_very_long_audio(self): """30s audio should work (will be truncated to 10s by analyzer).""" samples = np.random.randn(22050 * 30).astype(np.float32) * 0.1 path = _write_wav(samples) try: f = extract_features(path) cands = rank_candidates(f, "Vacuum cleaner") assert len(cands) >= 1 assert f.duration_s <= 10.1 # truncated to ~10s finally: os.unlink(path) def test_high_frequency_squeal(self): """Pure 4kHz tone should trigger tonal rules.""" samples = _make_tone(4000, 3.0, amplitude=0.5) path = _write_wav(samples) try: f = extract_features(path) assert f.spectral_centroid_hz > 3000 assert f.harmonic_ratio > 0.5 finally: os.unlink(path) def test_low_frequency_rumble(self): """Pure 40Hz tone should trigger rumble rules.""" samples = _make_tone(40, 3.0, amplitude=0.5) path = _write_wav(samples) try: f = extract_features(path) assert f.spectral_centroid_hz < 200 finally: os.unlink(path) def test_regular_click_pattern(self): """Regular amplitude-modulated clicks at 150ms should be detected as pattern. We generate a continuous tone with periodic amplitude dips — this is closer to what real onset detection can lock onto (the amplitude envelope modulation rather than isolated noise bursts). """ sr = 22050 interval_ms = 150 interval_s = interval_ms / 1000.0 duration = 4.0 n_samples = int(sr * duration) t = np.linspace(0, duration, n_samples, endpoint=False) # Base tone at 200 Hz signal = 0.3 * np.sin(2 * np.pi * 200 * t).astype(np.float32) # Add periodic amplitude modulation (clicks) at 150ms intervals click_envelope = np.ones(n_samples, dtype=np.float32) click_len = int(0.02 * sr) # 20ms dip n_clicks = int(duration / interval_s) for i in range(n_clicks): pos = int(i * interval_s * sr) if pos + click_len < n_samples: # Sharp amplitude dip (onset detector sees these as events) click_envelope[pos:pos+click_len] = 0.05 signal *= click_envelope # Add a small noise floor signal += 0.005 * np.random.randn(n_samples).astype(np.float32) path = _write_wav(signal, sr) try: f = extract_features(path) # The onset detector should find regular events assert f.onset_rate_per_sec > 2.0, ( f"should detect onsets, got rate={f.onset_rate_per_sec}" ) finally: os.unlink(path) # ============================================================================= # SECTION 4: Integration tests (full pipeline) # ============================================================================= class TestIntegration: """Test the full pipeline: audio -> features -> rules -> prompt -> validate.""" def test_full_pipeline_bearing(self): """Bearing sample should produce grounded diagnosis.""" samples = _make_clicks(0.15, 15, amplitude=0.5) # 150ms intervals path = _write_wav(samples) try: f = extract_features(path) cands = rank_candidates(f, "Washing machine") prompt = build_diagnosis_prompt(f, cands, "Washing machine") # Mock response response = json.dumps({ "fault": cands[0].name, "urgency": cands[0].urgency, "checks": ["Inspect the bearing.", "Listen again.", "Call tech."], "safety": "Disconnect power.", "confidence": 85, }) result = validate(response, cands) assert isinstance(result, DiagnosisResult) assert result.grounded assert result.confidence > 0 assert len(result.checks) > 0 finally: os.unlink(path) def test_full_pipeline_ungrounded_output(self): """Model output naming a non-candidate should be snapped back.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.03, zero_crossing_rate=0.08, spectral_centroid_hz=2200, spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, dominant_frequency_hz=180.0, harmonic_ratio=0.65, onset_rate_per_sec=3.5, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, ) cands = rank_candidates(f, "Washing machine") # Model returns a fault NOT in candidates response = json.dumps({ "fault": "Exploding capacitor", "urgency": "CRITICAL", "checks": ["Check capacitor."], "safety": "Unplug.", "confidence": 95, }) result = validate(response, cands) assert result.grounded # should be snapped to a candidate assert result.fault != "Exploding capacitor" def test_full_pipeline_malformed_json(self): """Malformed JSON should fall back to top candidate.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.01, zero_crossing_rate=0.05, spectral_centroid_hz=500, spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, dominant_frequency_hz=60.0, harmonic_ratio=0.5, onset_rate_per_sec=0.8, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Electric fan") result = validate("This is not JSON at all!", cands) assert result.grounded assert result.fault == cands[0].name def test_full_pipeline_empty_response(self): """Empty model response should fall back gracefully.""" f = AudioFeatures( duration_s=8.0, rms_db=-28.0, rms_variance=0.04, zero_crossing_rate=0.12, spectral_centroid_hz=1800, spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, dominant_frequency_hz=200.0, harmonic_ratio=0.7, onset_rate_per_sec=5.0, has_regular_pattern=True, pattern_interval_ms=80.0, peak_db=-12.0, anomaly_score=0.85, ) cands = rank_candidates(f, "Car engine") result = validate("", cands) assert result.grounded assert result.fault == cands[0].name def test_prompt_contains_all_features(self): """Prompt should contain all measured feature values.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.03, zero_crossing_rate=0.08, spectral_centroid_hz=2200, spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, dominant_frequency_hz=180.0, harmonic_ratio=0.65, onset_rate_per_sec=3.5, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, ) cands = rank_candidates(f, "Washing machine") prompt = build_diagnosis_prompt(f, cands, "Washing machine") assert "Washing machine" in prompt assert "2200" in prompt # spectral centroid assert "180" in prompt # dominant freq assert "0.65" in prompt # harmonic ratio assert "150" in prompt # pattern interval def test_validate_urgency_bounds(self): """Confidence should always be 0-100.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.01, zero_crossing_rate=0.05, spectral_centroid_hz=500, spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, dominant_frequency_hz=60.0, harmonic_ratio=0.5, onset_rate_per_sec=0.8, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Electric fan") # Test with extreme confidence values for conf in [-50, 0, 50, 100, 150, 999]: response = json.dumps({ "fault": cands[0].name, "urgency": "HIGH", "checks": ["Check it."], "safety": "None", "confidence": conf, }) result = validate(response, cands) assert 0 <= result.confidence <= 100 def test_validate_invalid_urgency(self): """Invalid urgency should fall back to candidate urgency.""" f = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.03, zero_crossing_rate=0.08, spectral_centroid_hz=2200, spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, dominant_frequency_hz=180.0, harmonic_ratio=0.65, onset_rate_per_sec=3.5, has_regular_pattern=True, pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, ) cands = rank_candidates(f, "Washing machine") response = json.dumps({ "fault": cands[0].name, "urgency": "SUPER_CRITICAL_URGENT", "checks": ["Check it."], "safety": "None", "confidence": 85, }) result = validate(response, cands) assert result.urgency in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") def test_validate_empty_checks(self): """Empty checks list should get default checks.""" f = AudioFeatures( duration_s=8.0, rms_db=-30.0, rms_variance=0.01, zero_crossing_rate=0.05, spectral_centroid_hz=500, spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, dominant_frequency_hz=60.0, harmonic_ratio=0.5, onset_rate_per_sec=0.8, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, ) cands = rank_candidates(f, "Electric fan") response = json.dumps({ "fault": cands[0].name, "urgency": "HIGH", "checks": [], "safety": "None", "confidence": 80, }) result = validate(response, cands) assert len(result.checks) >= 1 def test_candidates_always_returned(self): """rank_candidates should always return at least one candidate.""" # Test with extreme feature values extreme_features = [ AudioFeatures( duration_s=0.0, rms_db=-80.0, rms_variance=0.0, zero_crossing_rate=0.0, spectral_centroid_hz=0.0, spectral_bandwidth_hz=0.0, spectral_rolloff_hz=0.0, dominant_frequency_hz=0.0, harmonic_ratio=0.0, onset_rate_per_sec=0.0, has_regular_pattern=False, pattern_interval_ms=0.0, peak_db=-80.0, anomaly_score=0.0, ), AudioFeatures( duration_s=10.0, rms_db=0.0, rms_variance=1.0, zero_crossing_rate=1.0, spectral_centroid_hz=10000.0, spectral_bandwidth_hz=10000.0, spectral_rolloff_hz=11000.0, dominant_frequency_hz=5000.0, harmonic_ratio=1.0, onset_rate_per_sec=100.0, has_regular_pattern=True, pattern_interval_ms=1.0, peak_db=0.0, anomaly_score=1.0, ), ] for f in extreme_features: for appliance in RULES.keys(): cands = rank_candidates(f, appliance) assert len(cands) >= 1, f"No candidates for {appliance}" def test_all_rules_fires_at_least_one(self): """Each rule table should have at least one rule that fires for a typical input.""" # Create a "typical bad" feature set for each appliance typical_bad = AudioFeatures( duration_s=8.0, rms_db=-25.0, rms_variance=0.03, zero_crossing_rate=0.1, spectral_centroid_hz=2000, spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, dominant_frequency_hz=150.0, harmonic_ratio=0.5, onset_rate_per_sec=3.0, has_regular_pattern=True, pattern_interval_ms=120.0, peak_db=-18.0, anomaly_score=0.7, ) for appliance in RULES.keys(): cands = rank_candidates(typical_bad, appliance) assert len(cands) >= 1, \ f"No rules fired for {appliance} with typical bad input"