""" Tests for ESPIDFCompiler library resolution logic. Tests the methods that detect, locate, and package external Arduino libraries as proper ESP-IDF components — without requiring the full ESP-IDF toolchain. Run from the backend/ directory: python test_espidf_compiler.py """ import sys import tempfile import shutil import unittest from pathlib import Path # Ensure backend/app is importable sys.path.insert(0, str(Path(__file__).parent)) from app.services.espidf_compiler import ESPIDFCompiler # ── Helpers ─────────────────────────────────────────────────────────────────── def make_compiler() -> ESPIDFCompiler: """Create an ESPIDFCompiler instance without a real IDF path.""" comp = ESPIDFCompiler.__new__(ESPIDFCompiler) comp.idf_path = '' comp.arduino_path = '' comp.has_arduino = False return comp def make_library(libs_dir: Path, lib_name: str, headers: list[str], sources: list[str], use_src_subdir: bool = False) -> Path: """ Create a mock Arduino library directory structure. libs_dir/ {lib_name}/ {lib_name}.h {lib_name}.cpp [src/] ... """ lib_dir = libs_dir / lib_name lib_dir.mkdir(parents=True) src_dir = lib_dir / 'src' if use_src_subdir else lib_dir if use_src_subdir: src_dir.mkdir() for h in headers: (src_dir / h).write_text(f'// {h}', encoding='utf-8') for s in sources: (src_dir / s).write_text(f'// {s}', encoding='utf-8') return lib_dir # ── Test: _detect_external_includes ────────────────────────────────────────── class TestDetectExternalIncludes(unittest.TestCase): def setUp(self): self.comp = make_compiler() def test_detects_dht_header(self): code = '#include \nvoid setup() {}' result = self.comp._detect_external_includes(code) self.assertIn('DHT.h', result) def test_skips_arduino_builtins(self): code = '#include \n#include \n#include \n#include ' result = self.comp._detect_external_includes(code) self.assertEqual(result, []) def test_skips_esp_idf_headers(self): code = '#include \n#include \n#include ' result = self.comp._detect_external_includes(code) self.assertEqual(result, []) def test_skips_path_headers(self): # Headers with / are internal esp-idf or arduino-esp32 paths code = '#include \n#include ' result = self.comp._detect_external_includes(code) self.assertEqual(result, []) def test_detects_multiple_external(self): code = ( '#include \n' '#include \n' '#include \n' '#include \n' ) result = self.comp._detect_external_includes(code) self.assertIn('DHT.h', result) self.assertIn('Adafruit_Sensor.h', result) self.assertNotIn('Arduino.h', result) self.assertNotIn('Wire.h', result) def test_handles_whitespace_in_include(self): code = '# include ' result = self.comp._detect_external_includes(code) self.assertIn('DHT.h', result) def test_no_duplicates_from_multiple_files(self): code = '#include \n#include \n' result = self.comp._detect_external_includes(code) # List may have duplicates — the caller deduplicates; just check presence self.assertIn('DHT.h', result) def test_empty_sketch(self): result = self.comp._detect_external_includes('void setup() {} void loop() {}') self.assertEqual(result, []) # ── Test: _find_library_for_header ─────────────────────────────────────────── class TestFindLibraryForHeader(unittest.TestCase): def setUp(self): self.comp = make_compiler() self.tmp = tempfile.mkdtemp() self.libs_dir = Path(self.tmp) def tearDown(self): shutil.rmtree(self.tmp) def test_finds_library_in_root(self): """Library with header in root (no src/ subdirectory).""" make_library(self.libs_dir, 'DHT_sensor_library', headers=['DHT.h', 'DHT_U.h'], sources=['DHT.cpp', 'DHT_U.cpp']) result = self.comp._find_library_for_header('DHT.h', self.libs_dir) assert result is not None self.assertTrue((result / 'DHT.h').exists()) def test_finds_library_in_src_subdir(self): """Library with header in src/ subdirectory (modern Arduino layout).""" make_library(self.libs_dir, 'Adafruit_Sensor', headers=['Adafruit_Sensor.h'], sources=['Adafruit_Sensor.cpp'], use_src_subdir=True) result = self.comp._find_library_for_header('Adafruit_Sensor.h', self.libs_dir) assert result is not None self.assertTrue((result / 'Adafruit_Sensor.h').exists()) def test_returns_none_for_missing_library(self): make_library(self.libs_dir, 'SomeOtherLib', headers=['Other.h'], sources=['Other.cpp']) result = self.comp._find_library_for_header('DHT.h', self.libs_dir) self.assertIsNone(result) def test_returns_none_for_empty_dir(self): result = self.comp._find_library_for_header('DHT.h', self.libs_dir) self.assertIsNone(result) # ── Test: _create_idf_component ────────────────────────────────────────────── class TestCreateIdfComponent(unittest.TestCase): def setUp(self): self.comp = make_compiler() self.tmp = tempfile.mkdtemp() self.libs_dir = Path(self.tmp) / 'arduino_libs' self.libs_dir.mkdir() self.user_libs_dir = Path(self.tmp) / 'user_libs' self.user_libs_dir.mkdir() def tearDown(self): shutil.rmtree(self.tmp) def _make_dht_library(self, use_src: bool = False) -> Path: return make_library( self.libs_dir, 'DHT_sensor_library', headers=['DHT.h', 'DHT_U.h'], sources=['DHT.cpp', 'DHT_U.cpp'], use_src_subdir=use_src, ) def test_creates_component_directory(self): lib_dir = self._make_dht_library() src_root = lib_dir # header is in root self.comp._create_idf_component('DHT.h', src_root, self.user_libs_dir, 'arduino-esp32') self.assertTrue(self.user_libs_dir.exists()) comp_dirs = list(self.user_libs_dir.iterdir()) self.assertEqual(len(comp_dirs), 1) def test_component_has_cmake_lists(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) cmake_path = self.user_libs_dir / comp_name / 'CMakeLists.txt' self.assertTrue(cmake_path.exists(), 'CMakeLists.txt not found') def test_cmake_contains_idf_component_register(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() self.assertIn('idf_component_register', cmake_text) def test_cmake_includes_cpp_source(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() self.assertIn('DHT.cpp', cmake_text) def test_cmake_requires_arduino_component(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() self.assertIn('arduino-esp32', cmake_text) self.assertIn('REQUIRES', cmake_text) def test_cmake_sets_include_dirs(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() self.assertIn('INCLUDE_DIRS', cmake_text) self.assertIn('"."', cmake_text) def test_header_files_are_copied(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) comp_dir = self.user_libs_dir / comp_name self.assertTrue((comp_dir / 'DHT.h').exists()) self.assertTrue((comp_dir / 'DHT_U.h').exists()) def test_cpp_files_are_copied(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) comp_dir = self.user_libs_dir / comp_name self.assertTrue((comp_dir / 'DHT.cpp').exists()) self.assertTrue((comp_dir / 'DHT_U.cpp').exists()) def test_returns_sanitised_component_name(self): lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32' ) # Component name must be a valid C identifier (no spaces, no dots) self.assertRegex(comp_name, r'^[A-Za-z0-9_]+$') # Must be based on library directory name, not the parent search dir self.assertEqual(comp_name, 'DHT_sensor_library') def test_custom_arduino_comp_name(self): """arduino_comp_name is properly forwarded into REQUIRES.""" lib_dir = self._make_dht_library() comp_name = self.comp._create_idf_component( 'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32-custom' ) cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() self.assertIn('arduino-esp32-custom', cmake_text) def test_library_in_src_subdir(self): """Library with modern src/ layout is handled correctly.""" lib_dir = self._make_dht_library(use_src=True) src_root = lib_dir / 'src' comp_name = self.comp._create_idf_component( 'DHT.h', src_root, self.user_libs_dir, 'arduino-esp32' ) comp_dir = self.user_libs_dir / comp_name self.assertTrue((comp_dir / 'DHT.h').exists()) self.assertTrue((comp_dir / 'DHT.cpp').exists()) # ── Test: template CMakeLists.txt content ──────────────────────────────────── class TestTemplateCMakeLists(unittest.TestCase): def test_root_cmake_has_user_libs_block(self): template_cmake = ( Path(__file__).parent / 'app' / 'services' / 'esp-idf-template' / 'CMakeLists.txt' ) self.assertTrue(template_cmake.exists(), 'Template CMakeLists.txt not found') content = template_cmake.read_text(encoding='utf-8') self.assertIn('user_libs', content, 'user_libs not referenced in root CMakeLists.txt') self.assertIn('EXTRA_COMPONENT_DIRS', content) self.assertIn('EXISTS', content, 'user_libs block should use EXISTS guard') def test_main_cmake_has_arduino_requires(self): main_cmake = ( Path(__file__).parent / 'app' / 'services' / 'esp-idf-template' / 'main' / 'CMakeLists.txt' ) self.assertTrue(main_cmake.exists()) content = main_cmake.read_text(encoding='utf-8') self.assertIn('REQUIRES', content) self.assertIn('_arduino_comp_name', content) # ── Runner ──────────────────────────────────────────────────────────────────── if __name__ == '__main__': print('=' * 60) print('ESPIDFCompiler Library Resolution Tests') print('=' * 60) loader = unittest.TestLoader() suite = unittest.TestSuite() for cls in [ TestDetectExternalIncludes, TestFindLibraryForHeader, TestCreateIdfComponent, TestTemplateCMakeLists, ]: suite.addTests(loader.loadTestsFromTestCase(cls)) runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) sys.exit(0 if result.wasSuccessful() else 1)