diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 0a074dea..48587c65 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -359,20 +359,161 @@ class ESPIDFCompiler: logger.warning('[espidf] Arduino libraries dir not found') return None - # Built-in headers that do NOT require external library source + # Headers that will NEVER appear in any Arduino library directory: + # - C/C++ standard library headers + # - Arduino core API types compiled directly into arduino-esp32 (not installable) + # + # Everything else (Wire.h, SPI.h, WiFi.h, Adafruit_GFX.h, …) is resolved + # dynamically: user-installed libs → IDF component; arduino-esp32 bundled + # libs → skip (already compiled in); not found → warning. _BUILTIN_HEADERS = frozenset({ - 'Arduino.h', 'Wire.h', 'SPI.h', 'SPI.H', 'WiFi.h', 'EEPROM.h', - 'SD.h', 'Servo.h', 'LiquidCrystal.h', 'Ethernet.h', 'IPAddress.h', - 'HardwareSerial.h', 'Stream.h', 'Print.h', 'WString.h', 'pgmspace.h', + # C/C++ standard library 'math.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'string.h', 'stdarg.h', - 'WebServer.h', 'HTTPClient.h', 'WiFiClient.h', 'WiFiServer.h', - 'BluetoothSerial.h', 'BLEDevice.h', - 'FS.h', 'SPIFFS.h', 'LittleFS.h', - 'esp_system.h', 'esp_wifi.h', 'esp_event.h', 'nvs_flash.h', - 'freertos/FreeRTOS.h', 'freertos/task.h', - 'Adafruit_GFX.h', # bundled with arduino-esp32 sometimes + 'stddef.h', 'stdbool.h', 'float.h', 'limits.h', 'assert.h', + # Arduino core types — part of arduino-esp32 source, not installable libraries + 'Arduino.h', 'HardwareSerial.h', 'Stream.h', 'Print.h', 'WString.h', + 'pgmspace.h', 'IPAddress.h', }) + # Core arduino-esp32 bundled libraries — already compiled into the IDF component, + # must NOT be duplicated as separate user_libs components. + _CORE_ESP32_LIBS: frozenset[str] = frozenset({ + 'Wire', 'SPI', 'WiFi', 'EEPROM', 'SD', 'FS', + 'LittleFS', 'SPIFFS', 'WebServer', 'HTTPClient', + 'WiFiClientSecure', 'BluetoothSerial', 'BLE', + 'Preferences', 'Update', 'Ticker', + }) + + def _resolve_library_components( + self, + ext_headers: list[str], + arduino_libs: Path | None, + esp32_libs: Path | None, + arduino_comp_name: str, + user_libs_dir: Path, + ) -> tuple[list[str], dict[str, str]]: + """ + BFS over ext_headers (and transitive includes) to discover all external + Arduino libraries and merge them into a single 'user_libs_all' IDF component. + + All library files are copied flat into one directory, so every header is + visible to every other header and source file without any cross-component + REQUIRES propagation — which is unreliable in ESP-IDF 4.x for deeply + nested transitive dependencies. + + Search priority per header: + 1. arduino_libs (user-installed via Library Manager) → merge into component + 2. esp32_libs (bundled with arduino-esp32) → skip core libs (Wire, SPI, …); + merge non-core libs (e.g. Adafruit libs shipped with arduino-esp32) + 3. not found → warning only + + Returns: + component_names — ['user_libs_all'] if any lib found, else [] + header_to_comp — every resolved header → 'user_libs_all' + """ + logger.info(f'[espidf] ext_headers detected: {ext_headers}') + logger.info(f'[espidf] arduino_libs: {arduino_libs}') + logger.info(f'[espidf] esp32_libs: {esp32_libs}') + + comp_dir = user_libs_dir / 'user_libs_all' + comp_dir.mkdir(exist_ok=True) + + cpp_files: list[str] = [] + seen_names: set[str] = set() + header_to_comp: dict[str, str] = {} + found_any = False + + headers_to_resolve: list[str] = list(ext_headers) + resolved_headers: set[str] = set() + + while headers_to_resolve: + header = headers_to_resolve.pop(0) + if header in resolved_headers: + continue + resolved_headers.add(header) + + src_root = ( + self._find_library_for_header(header, arduino_libs) + if arduino_libs and arduino_libs.is_dir() + else None + ) + + if src_root is None and esp32_libs and esp32_libs.is_dir(): + esp32_root = self._find_library_for_header(header, esp32_libs) + if esp32_root: + lib_name = esp32_root.parent.name if esp32_root.name == 'src' else esp32_root.name + if lib_name in self._CORE_ESP32_LIBS: + logger.debug(f'[espidf] <{header}> is bundled core lib "{lib_name}", skipping') + else: + logger.info(f'[espidf] <{header}> found in esp32_libs as "{lib_name}", merging') + src_root = esp32_root + + if src_root: + lib_dir_name = src_root.parent.name if src_root.name == 'src' else src_root.name + logger.info(f'[espidf] Merging "{lib_dir_name}" into user_libs_all for <{header}>') + found_any = True + header_to_comp[header] = 'user_libs_all' + + # Copy all source files flat into the merged component directory. + # First-writer wins for name conflicts (rare across Arduino libs). + for pattern in ('*.h', '*.cpp', '*.c', 'src/*.h', 'src/*.cpp', 'src/*.c'): + glob_root = src_root.parent if pattern.startswith('src/') else src_root + for f in glob_root.glob(pattern): + if not f.is_file(): + continue + if f.name not in seen_names: + shutil.copy2(f, comp_dir / f.name) + seen_names.add(f.name) + if f.suffix in ('.cpp', '.c') and f.name not in cpp_files: + cpp_files.append(f.name) + + # Also handle libraries that use an src/ subdirectory layout. + src_sub = (src_root / 'src') if (src_root / 'src').is_dir() else None + if src_sub is None and (src_root.parent / 'src').is_dir(): + src_sub = src_root.parent / 'src' + if src_sub: + for f in src_sub.glob('**/*'): + if not f.is_file() or f.suffix not in ('.h', '.cpp', '.c'): + continue + if f.name not in seen_names: + shutil.copy2(f, comp_dir / f.name) + seen_names.add(f.name) + if f.suffix in ('.cpp', '.c') and f.name not in cpp_files: + cpp_files.append(f.name) + + # Scan newly copied headers for transitive includes. + for lib_file in comp_dir.glob('*.h'): + try: + lib_content = lib_file.read_text(encoding='utf-8', errors='ignore') + for th in self._detect_external_includes(lib_content): + if th not in resolved_headers: + headers_to_resolve.append(th) + except OSError: + pass + else: + logger.warning(f'[espidf] Library for <{header}> not found — build may fail') + + if not found_any: + return [], {} + + srcs_line = 'SRCS ' + ' '.join(f'"{f}"' for f in sorted(cpp_files)) if cpp_files else '' + cmake_content = ( + '# Auto-generated by Velxio — all user libraries merged into one component.\n' + '# Single flat directory: every header sees every other header without\n' + '# cross-component REQUIRES propagation.\n' + 'idf_component_register(\n' + f' {srcs_line}\n' + ' INCLUDE_DIRS "."\n' + f' REQUIRES {arduino_comp_name}\n' + ')\n' + ) + (comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8') + logger.info( + f'[espidf] user_libs_all: {len(cpp_files)} source files, ' + f'{len(header_to_comp)} resolved headers' + ) + return ['user_libs_all'], header_to_comp + def _detect_external_includes(self, code: str) -> list[str]: """Return library header names that are likely from external libraries.""" headers = [] @@ -666,89 +807,34 @@ class ESPIDFCompiler: user_libs_dir = project_dir / 'user_libs' user_libs_dir.mkdir(exist_ok=True) - # Search order: esp32-bundled libraries first, then user-installed - esp32_libs = Path(self.arduino_path) / 'libraries' if self.arduino_path else None + esp32_libs = Path(self.arduino_path) / 'libraries' if self.arduino_path else None arduino_libs = self._find_arduino_libraries_dir() - search_bases = [b for b in [esp32_libs, arduino_libs] if b and b.is_dir()] - # Phase 1: BFS to discover all needed libraries including transitives - headers_to_resolve: list[str] = list(ext_headers) - resolved_headers: set[str] = set() - header_to_comp: dict[str, str] = {} # header → component name + component_names, _ = self._resolve_library_components( + ext_headers, arduino_libs, esp32_libs, + arduino_comp_name, user_libs_dir, + ) - while headers_to_resolve: - header = headers_to_resolve.pop(0) - if header in resolved_headers: - continue - resolved_headers.add(header) - - src_root = None - for search_base in search_bases: - src_root = self._find_library_for_header(header, search_base) - if src_root: - break - - if src_root: - comp_name = self._create_idf_component( - header, src_root, user_libs_dir, arduino_comp_name - ) - if comp_name not in component_names: - component_names.append(comp_name) - header_to_comp[header] = comp_name - - # Scan copied library files for further external includes - comp_dir = user_libs_dir / comp_name - for lib_file in comp_dir.glob('*.h'): - try: - lib_content = lib_file.read_text(encoding='utf-8', errors='ignore') - for th in self._detect_external_includes(lib_content): - if th not in resolved_headers: - headers_to_resolve.append(th) - except OSError: - pass - else: - logger.warning(f'[espidf] Library for <{header}> not found — build may fail') - - # Phase 2: patch inter-component REQUIRES so each component can - # see the headers of libraries it transitively includes - for comp_name in component_names: - comp_dir = user_libs_dir / comp_name - extra_reqs: list[str] = [] - for lib_file in comp_dir.glob('*.h'): - try: - content = lib_file.read_text(encoding='utf-8', errors='ignore') - for dep_h in self._detect_external_includes(content): - dep_comp = header_to_comp.get(dep_h) - if dep_comp and dep_comp != comp_name and dep_comp not in extra_reqs: - extra_reqs.append(dep_comp) - except OSError: - pass - if extra_reqs: - cmake_path = comp_dir / 'CMakeLists.txt' - cmake_text = cmake_path.read_text(encoding='utf-8') - cmake_text = cmake_text.replace( - f'REQUIRES {arduino_comp_name}', - f'REQUIRES {arduino_comp_name} {" ".join(extra_reqs)}', - ) - cmake_path.write_text(cmake_text, encoding='utf-8') - logger.info(f'[espidf] {comp_name} REQUIRES += {extra_reqs}') - - # Patch main/CMakeLists.txt to REQUIRES the library components. - # The template uses CMake variable syntax: REQUIRES ${_arduino_comp_name} - if component_names: + # Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all. + # The single merged component means one entry covers all external headers. + if component_names: # always ['user_libs_all'] when any lib was found cmake_path = project_dir / 'main' / 'CMakeLists.txt' cmake_text = cmake_path.read_text(encoding='utf-8') - main_reqs = ' '.join(component_names) - # Replace both possible forms: CMake variable (template) or literal (pre-patched) + for old_req in [r'REQUIRES ${_arduino_comp_name}', f'REQUIRES {arduino_comp_name}']: if old_req in cmake_text: cmake_text = cmake_text.replace( - old_req, - f'{old_req} {main_reqs}', + old_req, f'{old_req} user_libs_all' ) break + + cmake_text = cmake_text.replace( + 'INCLUDE_DIRS "."', + 'INCLUDE_DIRS "." "../user_libs/user_libs_all"', + ) + cmake_path.write_text(cmake_text, encoding='utf-8') - logger.info(f'[espidf] Added {len(component_names)} library component(s) to REQUIRES') + logger.info('[espidf] Patched main CMakeLists: REQUIRES += user_libs_all, INCLUDE_DIRS += user_libs_all') else: # Pure ESP-IDF mode: translate sketch translated = self._translate_sketch_to_espidf(main_content) diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 30c7e57a..8ecb8f8d 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -589,23 +589,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
- {/* Status message */} - {message && ( - - {message.type === 'success' ? ( - - - - ) : ( - - - - - - )} - {message.text} - - )} + {/* Hidden file input for import (always present) */} ESPIDFCompiler: - """Create an ESPIDFCompiler instance without a real IDF path.""" comp = ESPIDFCompiler.__new__(ESPIDFCompiler) comp.idf_path = '' comp.arduino_path = '' @@ -33,28 +31,15 @@ def make_compiler() -> ESPIDFCompiler: 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 @@ -67,24 +52,42 @@ class TestDetectExternalIncludes(unittest.TestCase): def test_detects_dht_header(self): code = '#include \nvoid setup() {}' - result = self.comp._detect_external_includes(code) - self.assertIn('DHT.h', result) + self.assertIn('DHT.h', self.comp._detect_external_includes(code)) - def test_skips_arduino_builtins(self): - code = '#include \n#include \n#include \n#include ' + def test_detects_adafruit_gfx(self): + """Adafruit_GFX.h must NOT be silently skipped — it is a user-installed lib.""" + code = '#include ' + result = self.comp._detect_external_includes(code) + self.assertIn('Adafruit_GFX.h', result, + 'Adafruit_GFX.h was silently skipped — should be detected for library resolution') + + def test_detects_adafruit_ssd1306(self): + code = '#include ' + self.assertIn('Adafruit_SSD1306.h', self.comp._detect_external_includes(code)) + + def test_skips_arduino_core_types(self): + """Arduino.h and core types are in BUILTIN_HEADERS — skip them.""" + code = '#include \n#include \n#include ' result = self.comp._detect_external_includes(code) self.assertEqual(result, []) + def test_detects_wire_and_spi(self): + """Wire.h and SPI.h are no longer in BUILTIN_HEADERS; they are resolved + dynamically (skipped as bundled in esp32_libs or missing → warn).""" + code = '#include \n#include \n#include ' + result = self.comp._detect_external_includes(code) + # They should be detected (not silently dropped here) + self.assertIn('Wire.h', result) + self.assertIn('SPI.h', result) + self.assertIn('WiFi.h', result) + def test_skips_esp_idf_headers(self): code = '#include \n#include \n#include ' - result = self.comp._detect_external_includes(code) - self.assertEqual(result, []) + self.assertEqual([], self.comp._detect_external_includes(code)) 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, []) + self.assertEqual([], self.comp._detect_external_includes(code)) def test_detects_multiple_external(self): code = ( @@ -97,22 +100,13 @@ class TestDetectExternalIncludes(unittest.TestCase): 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) + self.assertIn('DHT.h', self.comp._detect_external_includes(code)) def test_empty_sketch(self): - result = self.comp._detect_external_includes('void setup() {} void loop() {}') - self.assertEqual(result, []) + self.assertEqual([], self.comp._detect_external_includes('void setup() {} void loop() {}')) # ── Test: _find_library_for_header ─────────────────────────────────────────── @@ -128,31 +122,25 @@ class TestFindLibraryForHeader(unittest.TestCase): 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']) + headers=['DHT.h', 'DHT_U.h'], sources=['DHT.cpp']) result = self.comp._find_library_for_header('DHT.h', self.libs_dir) - assert result is not None + self.assertIsNotNone(result) 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()) + self.assertIsNotNone(result) 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) + make_library(self.libs_dir, 'SomeOtherLib', headers=['Other.h'], sources=[]) + self.assertIsNone(self.comp._find_library_for_header('DHT.h', self.libs_dir)) def test_returns_none_for_empty_dir(self): - result = self.comp._find_library_for_header('DHT.h', self.libs_dir) - self.assertIsNone(result) + self.assertIsNone(self.comp._find_library_for_header('DHT.h', self.libs_dir)) # ── Test: _create_idf_component ────────────────────────────────────────────── @@ -171,113 +159,250 @@ class TestCreateIdfComponent(unittest.TestCase): 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, - ) + 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) + self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32') + self.assertEqual(len(list(self.user_libs_dir.iterdir())), 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') + comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32') + self.assertTrue((self.user_libs_dir / comp_name / 'CMakeLists.txt').exists()) 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' - ) + 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' - ) + 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' - ) + 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' - ) + 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_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) + comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32') 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' + +# ── Test: _resolve_library_components (SSD1306 / transitive scenario) ───────── + +class TestResolveLibraryComponents(unittest.TestCase): + """ + Exercises the BFS that merges all libraries into a single 'user_libs_all' + IDF component. This avoids ESP-IDF cross-component REQUIRES propagation issues. + + Mock library structure mirrors the real Adafruit SSD1306 scenario: + arduino_libs/ + Adafruit_GFX_Library/ + Adafruit_GFX.h + Adafruit_GFX.cpp + Adafruit_SSD1306/ + Adafruit_SSD1306.h ← includes + Adafruit_SSD1306.cpp + + esp32_libs/ + Wire/Wire.h, Wire.cpp ← core, must be skipped + SPI/SPI.h, SPI.cpp ← core, must be skipped + """ + + def setUp(self): + self.comp = make_compiler() + self.tmp = tempfile.mkdtemp() + self.arduino_libs = Path(self.tmp) / 'arduino_libs' + self.esp32_libs = Path(self.tmp) / 'esp32_libs' + self.user_libs = Path(self.tmp) / 'user_libs' + self.arduino_libs.mkdir() + self.esp32_libs.mkdir() + self.user_libs.mkdir() + + make_library(self.arduino_libs, 'Adafruit_GFX_Library', + headers=['Adafruit_GFX.h', 'Adafruit_SPITFT.h'], + sources=['Adafruit_GFX.cpp', 'Adafruit_SPITFT.cpp']) + + ssd_dir = self.arduino_libs / 'Adafruit_SSD1306' + ssd_dir.mkdir() + (ssd_dir / 'Adafruit_SSD1306.h').write_text( + '#include \n// SSD1306 header', encoding='utf-8' ) - cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text() - self.assertIn('arduino-esp32-custom', cmake_text) + (ssd_dir / 'Adafruit_SSD1306.cpp').write_text('// impl', encoding='utf-8') - 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' + make_library(self.esp32_libs, 'Wire', headers=['Wire.h'], sources=['Wire.cpp']) + make_library(self.esp32_libs, 'SPI', headers=['SPI.h'], sources=['SPI.cpp']) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _resolve(self, ext_headers): + return self.comp._resolve_library_components( + ext_headers, self.arduino_libs, self.esp32_libs, 'arduino-esp32', self.user_libs ) - comp_dir = self.user_libs_dir / comp_name - self.assertTrue((comp_dir / 'DHT.h').exists()) - self.assertTrue((comp_dir / 'DHT.cpp').exists()) + + def test_returns_single_merged_component(self): + """Any library resolution must produce exactly ['user_libs_all'].""" + names, _ = self._resolve(['Adafruit_GFX.h']) + self.assertEqual(names, ['user_libs_all'], + 'Expected single merged component "user_libs_all"') + + def test_gfx_header_merged_for_direct_include(self): + """Adafruit_GFX.h directly in sketch → copied into user_libs_all.""" + self._resolve(['Adafruit_GFX.h']) + self.assertTrue( + (self.user_libs / 'user_libs_all' / 'Adafruit_GFX.h').exists(), + 'Adafruit_GFX.h not copied into user_libs_all — will not be on include path', + ) + + def test_both_libs_merged_for_ssd1306_sketch(self): + """SSD1306 sketch: both GFX and SSD1306 files must be in user_libs_all.""" + self._resolve(['Wire.h', 'Adafruit_GFX.h', 'Adafruit_SSD1306.h']) + all_dir = self.user_libs / 'user_libs_all' + self.assertTrue((all_dir / 'Adafruit_GFX.h').exists(), + 'Adafruit_GFX.h missing — SSD1306.cpp will fail to compile') + self.assertTrue((all_dir / 'Adafruit_SSD1306.h').exists(), + 'Adafruit_SSD1306.h missing') + self.assertTrue((all_dir / 'Adafruit_GFX.cpp').exists()) + self.assertTrue((all_dir / 'Adafruit_SSD1306.cpp').exists()) + + def test_wire_files_not_in_merged_component(self): + """Wire is a core esp32 lib — must NOT be copied into user_libs_all.""" + self._resolve(['Wire.h', 'Adafruit_SSD1306.h']) + all_dir = self.user_libs / 'user_libs_all' + self.assertFalse((all_dir / 'Wire.h').exists(), + 'Wire.h was incorrectly copied — it is already in arduino-esp32') + + def test_transitive_gfx_discovered_via_ssd1306(self): + """When sketch only includes SSD1306, GFX is discovered transitively.""" + self._resolve(['Adafruit_SSD1306.h']) + self.assertTrue( + (self.user_libs / 'user_libs_all' / 'Adafruit_GFX.h').exists(), + 'Transitive Adafruit_GFX.h not discovered — SSD1306.h includes it', + ) + + def test_merged_component_cmake_has_all_sources(self): + """user_libs_all CMakeLists.txt must list both GFX and SSD1306 .cpp files.""" + self._resolve(['Adafruit_GFX.h', 'Adafruit_SSD1306.h']) + cmake_text = (self.user_libs / 'user_libs_all' / 'CMakeLists.txt').read_text() + self.assertIn('Adafruit_GFX.cpp', cmake_text) + self.assertIn('Adafruit_SSD1306.cpp', cmake_text) + self.assertIn('INCLUDE_DIRS "."', cmake_text) + self.assertIn('arduino-esp32', cmake_text) + + def test_header_to_comp_maps_to_user_libs_all(self): + """header_to_comp must map every resolved header to 'user_libs_all'.""" + _, h2c = self._resolve(['Adafruit_GFX.h', 'Adafruit_SSD1306.h']) + for h, c in h2c.items(): + self.assertEqual(c, 'user_libs_all', f'{h} mapped to "{c}" instead of "user_libs_all"') + + def test_no_components_when_no_external_headers(self): + """Empty ext_headers → no component created.""" + names, h2c = self._resolve([]) + self.assertEqual(names, []) + self.assertEqual(h2c, {}) -# ── Test: template CMakeLists.txt content ──────────────────────────────────── +# ── Test: main CMakeLists.txt patching ─────────────────────────────────────── + +class TestMainCMakePatching(unittest.TestCase): + """Verify that component_names are injected into main/CMakeLists.txt correctly.""" + + TEMPLATE_MAIN_CMAKE = ( + Path(__file__).parent.parent.parent.parent / 'backend' + / 'app' / 'services' / 'esp-idf-template' / 'main' / 'CMakeLists.txt' + ) + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _make_main_cmake(self) -> Path: + p = Path(self.tmp) / 'CMakeLists.txt' + shutil.copy(self.TEMPLATE_MAIN_CMAKE, p) + return p + + def test_template_cmake_has_expected_requires_token(self): + cmake_text = self.TEMPLATE_MAIN_CMAKE.read_text(encoding='utf-8') + self.assertIn('REQUIRES ${_arduino_comp_name}', cmake_text, + 'Template REQUIRES token not found — patching will silently do nothing') + + def test_patch_appends_component_names_to_requires(self): + cmake_path = self._make_main_cmake() + cmake_text = cmake_path.read_text(encoding='utf-8') + old_req = r'REQUIRES ${_arduino_comp_name}' + component_names = ['Adafruit_GFX_Library', 'Adafruit_SSD1306'] + main_reqs = ' '.join(component_names) + self.assertIn(old_req, cmake_text, 'Token not found — test setup error') + + cmake_text = cmake_text.replace(old_req, f'{old_req} {main_reqs}') + cmake_path.write_text(cmake_text, encoding='utf-8') + + result = cmake_path.read_text(encoding='utf-8') + self.assertIn('Adafruit_GFX_Library', result) + self.assertIn('Adafruit_SSD1306', result) + self.assertIn('${_arduino_comp_name}', result, + 'Original arduino_comp_name was removed — it must be preserved') + + def test_patch_adds_user_libs_all_include_dir(self): + """user_libs_all dir must be added to INCLUDE_DIRS so sketch.ino.cpp + can find all library headers directly.""" + cmake_path = self._make_main_cmake() + cmake_text = cmake_path.read_text(encoding='utf-8') + self.assertIn('INCLUDE_DIRS "."', cmake_text, 'Template INCLUDE_DIRS token missing') + + cmake_text = cmake_text.replace( + 'INCLUDE_DIRS "."', + 'INCLUDE_DIRS "." "../user_libs/user_libs_all"', + ) + cmake_path.write_text(cmake_text, encoding='utf-8') + + result = cmake_path.read_text(encoding='utf-8') + self.assertIn('../user_libs/user_libs_all', result, + 'user_libs_all INCLUDE_DIR not added — library headers invisible to sketch') + self.assertIn('"."', result, 'Original "." INCLUDE_DIR must be preserved') + + def test_patch_is_idempotent(self): + """Applying the patch twice must not duplicate entries.""" + cmake_path = self._make_main_cmake() + cmake_text = cmake_path.read_text(encoding='utf-8') + old_req = r'REQUIRES ${_arduino_comp_name}' + component_names = ['Adafruit_GFX_Library'] + + cmake_text = cmake_text.replace(old_req, f'{old_req} {" ".join(component_names)}') + count = cmake_text.count('Adafruit_GFX_Library') + self.assertEqual(count, 1) + + +# ── Test: template CMakeLists.txt structure ────────────────────────────────── class TestTemplateCMakeLists(unittest.TestCase): @@ -288,11 +413,9 @@ class TestTemplateCMakeLists(unittest.TestCase): ) 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('user_libs', content) self.assertIn('EXTRA_COMPONENT_DIRS', content) - self.assertIn('EXISTS', content, - 'user_libs block should use EXISTS guard') + self.assertIn('EXISTS', content) def test_main_cmake_has_arduino_requires(self): main_cmake = ( @@ -317,6 +440,8 @@ if __name__ == '__main__': TestDetectExternalIncludes, TestFindLibraryForHeader, TestCreateIdfComponent, + TestResolveLibraryComponents, + TestMainCMakePatching, TestTemplateCMakeLists, ]: suite.addTests(loader.loadTestsFromTestCase(cls))