diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 6d7f1cd3..670fcf44 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -894,6 +894,7 @@ class ESPIDFCompiler: arduino_comp_name: str, user_libs_dir: Path, allowed_libraries: set[str] | None = None, + merged_libs: dict[str, str] | None = None, ) -> tuple[list[str], dict[str, str]]: """ BFS over ext_headers (and transitive includes) to discover all external @@ -1039,6 +1040,14 @@ class ESPIDFCompiler: logger.info(f'[espidf] Merging "{lib_dir_name}" into user_libs_all for <{header}>') found_any = True header_to_comp[header] = 'user_libs_all' + if merged_libs is not None: + # Report the DISPLAY name (library.properties name=, the + # one the Library Manager shows) so the frontend can + # auto-declare it in the project manifest. Cache folder + # names carry a @version-hash suffix — strip it. + _lib_root = src_root.parent if src_root.name == 'src' else src_root + _props_name = self._parse_library_properties(_lib_root).get('name', '') + merged_libs[header] = _props_name or lib_dir_name.split('@', 1)[0] # Preserve directory structure while merging libraries. # Skip non-buildable directories like examples, tests, docs. @@ -1249,16 +1258,56 @@ class ESPIDFCompiler: def _find_library_for_header(self, header: str, libs_dir: Path) -> Path | None: """ - Search libs_dir for a library that provides `header`. - Returns the source root of the library (root or src/ subdirectory). + Search libs_dir for a library that provides `header`, preferring the + best-named candidate instead of the first alphabetical one. + + First-alphabetical was how (6 providers in the live cache) + could resolve to a random lib that merely ships a same-named file, + and how generic headers landed on unrelated libraries. Scoring: + + +100 normalized library name == normalized header stem + ("Servo.h" -> the lib actually named Servo/ESP32Servo...) + +40 library.properties architectures lists esp32 explicitly + (purpose-built beats a wildcard/absent declaration) + +20 library name CONTAINS the header stem, minus a length + penalty so "servo" outranks "simpleservoesp32" + + Alphabetical remains the final tie-break so resolution stays + deterministic. Libraries with score 0 still resolve (legacy + behaviour) — this only changes WHICH provider wins when several + exist. """ + stem = self._norm_lib_name(Path(header).stem) + best: tuple[float, str, Path] | None = None for lib_dir in sorted(libs_dir.iterdir()): if not lib_dir.is_dir(): continue - for src_root in [lib_dir, lib_dir / 'src']: - if (src_root / header).exists(): - return src_root - return None + src_root = None + for cand in (lib_dir, lib_dir / 'src'): + if (cand / header).exists(): + src_root = cand + break + if src_root is None: + continue + # Folder names in the shared cache look like "esp32servo@3.2.1-" + # — strip the version suffix before comparing. + folder = lib_dir.name.split('@', 1)[0] + props = self._parse_library_properties(lib_dir) + names = {self._norm_lib_name(folder)} + if props.get('name'): + names.add(self._norm_lib_name(props['name'])) + score = 0.0 + if stem and stem in names: + score += 100 + arch = {a.strip().lower() for a in props.get('architectures', '').split(',') if a.strip()} + if self._ESP32_LIB_ARCH in arch: + score += 40 + if stem and score < 100 and any(stem in n for n in names if n): + shortest = min((len(n) for n in names if stem in n), default=0) + score += max(5.0, 20.0 - (shortest - len(stem)) * 0.5) + if best is None or score > best[0]: + best = (score, lib_dir.name, src_root) + return best[2] if best else None def _create_idf_component( self, @@ -2263,6 +2312,12 @@ class ESPIDFCompiler: ) ext_headers = list(ext_headers_set) component_names: list[str] = [] + # header -> display name of the library the resolver merged for + # it. Fed back to the client on scan-all success so the project + # manifest can be auto-completed (79% of projects compile with an + # empty manifest today; this migrates them one green build at a + # time instead of ever breaking them). + merged_libs_report: dict[str, str] = {} # arduino-esp32 component name (directory basename of ARDUINO_ESP32_PATH) arduino_comp_name = Path(self.arduino_path).name if self.arduino_path else 'arduino-esp32' @@ -2282,6 +2337,7 @@ class ESPIDFCompiler: ext_headers, arduino_libs, esp32_libs, arduino_comp_name, user_libs_dir, allowed_libraries=allowed_libraries, + merged_libs=merged_libs_report, ) # Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all. @@ -2522,7 +2578,7 @@ class ESPIDFCompiler: binary_b64 = base64.b64encode(merged_path.read_bytes()).decode('ascii') logger.info(f'[espidf] Compilation successful — {len(binary_b64) // 1024} KB (base64), has_wifi={has_wifi}') - return { + result_ok: dict = { 'success': True, 'hex_content': None, 'binary_content': binary_b64, @@ -2531,6 +2587,16 @@ class ESPIDFCompiler: 'stdout': all_stdout, 'stderr': all_stderr, } + # Scan-all build (no manifest): tell the client exactly which + # libraries the build really used, shaped like the existing + # manifest_suggested_libraries ({header: [candidates]}) so one + # consumer handles both this and the incomplete-manifest retry. + if allowed_libraries is None and merged_libs_report: + result_ok['manifest_incomplete'] = True + result_ok['manifest_suggested_libraries'] = { + h: [name] for h, name in merged_libs_report.items() + } + return result_ok # Singleton instance diff --git a/frontend/src/__tests__/library-manifest.test.ts b/frontend/src/__tests__/library-manifest.test.ts new file mode 100644 index 00000000..84a313cb --- /dev/null +++ b/frontend/src/__tests__/library-manifest.test.ts @@ -0,0 +1,140 @@ +/** + * Library-manifest helpers (utils/libraryManifest.ts). + * + * These pure functions drive the 2026-08 manifest migration: + * - mergeSuggestedLibraries folds the backend's post-build report + * (`manifest_suggested_libraries`) into a board's declared manifest — + * conservatively: single-candidate entries only, normalized dedup, + * null when nothing changes (so callers skip the store write). + * - add/removeLibraryToManifest back the install-declares flow (agent + * install tool, Wokwi InstallLibrariesModal). + */ + +import { describe, it, expect } from 'vitest'; +import { + normalizeLibName, + bareLibName, + mergeSuggestedLibraries, + addLibraryToManifest, + removeLibraryFromManifest, +} from '../utils/libraryManifest'; + +describe('normalizeLibName', () => { + it('lowercases and strips non-alphanumerics', () => { + expect(normalizeLibName('Adafruit GFX Library')).toBe('adafruitgfxlibrary'); + expect(normalizeLibName('Adafruit_GFX_Library')).toBe('adafruitgfxlibrary'); + expect(normalizeLibName('ESP Async WebServer')).toBe('espasyncwebserver'); + }); + + it('handles empty / falsy input', () => { + expect(normalizeLibName('')).toBe(''); + }); +}); + +describe('bareLibName', () => { + it('strips a trailing @version', () => { + expect(bareLibName('DHT sensor library@1.4.6')).toBe('DHT sensor library'); + expect(bareLibName('ESP32Servo@3.2.1')).toBe('ESP32Servo'); + }); + + it('leaves version-less specs untouched', () => { + expect(bareLibName('ESP32Servo')).toBe('ESP32Servo'); + }); + + it('does not treat a leading @ as a version separator', () => { + expect(bareLibName('@scoped-thing')).toBe('@scoped-thing'); + }); +}); + +describe('mergeSuggestedLibraries', () => { + it('adds single-candidate suggestions to an empty manifest', () => { + const merged = mergeSuggestedLibraries(undefined, { + 'ESPAsyncWebServer.h': ['ESP Async WebServer'], + 'ESP32Servo.h': ['ESP32Servo'], + }); + expect(merged).toEqual(['ESP Async WebServer', 'ESP32Servo']); + }); + + it('skips ambiguous (multi-candidate) suggestions', () => { + const merged = mergeSuggestedLibraries([], { + 'Servo.h': ['Servo', 'ESP32Servo'], // ambiguous — never guess + 'AsyncTCP.h': ['AsyncTCP'], + }); + expect(merged).toEqual(['AsyncTCP']); + }); + + it('dedups against the current manifest with normalized comparison', () => { + const merged = mergeSuggestedLibraries(['esp async webserver'], { + 'ESPAsyncWebServer.h': ['ESP Async WebServer'], + 'AsyncTCP.h': ['AsyncTCP'], + }); + expect(merged).toEqual(['esp async webserver', 'AsyncTCP']); + }); + + it('dedups repeated suggestions across headers', () => { + const merged = mergeSuggestedLibraries([], { + 'FooA.h': ['FooLib'], + 'FooB.h': ['Foo_Lib'], // same library, different spelling + }); + expect(merged).toEqual(['FooLib']); + }); + + it('returns null when there is nothing new (no store churn)', () => { + expect(mergeSuggestedLibraries(['AsyncTCP'], { 'AsyncTCP.h': ['AsyncTCP'] })).toBeNull(); + expect(mergeSuggestedLibraries(['A'], null)).toBeNull(); + expect(mergeSuggestedLibraries(['A'], undefined)).toBeNull(); + expect(mergeSuggestedLibraries(['A'], {})).toBeNull(); + }); + + it('ignores empty or malformed candidate entries', () => { + const merged = mergeSuggestedLibraries([], { + 'A.h': [''], + 'B.h': [], + 'C.h': ['RealLib'], + }); + expect(merged).toEqual(['RealLib']); + }); + + it('preserves the existing manifest order and appends', () => { + const merged = mergeSuggestedLibraries(['First', 'Second'], { 'X.h': ['Third'] }); + expect(merged).toEqual(['First', 'Second', 'Third']); + }); +}); + +describe('addLibraryToManifest', () => { + it('adds a bare name from a versioned spec', () => { + expect(addLibraryToManifest(undefined, 'ESP32Servo@3.2.1')).toEqual(['ESP32Servo']); + }); + + it('returns null when already declared (normalized)', () => { + expect(addLibraryToManifest(['esp32servo'], 'ESP32Servo@3.2.1')).toBeNull(); + expect(addLibraryToManifest(['ESP Async WebServer'], 'esp_async_webserver')).toBeNull(); + }); + + it('appends to an existing manifest', () => { + expect(addLibraryToManifest(['A'], 'B')).toEqual(['A', 'B']); + }); + + it('rejects empty specs', () => { + expect(addLibraryToManifest(['A'], '')).toBeNull(); + expect(addLibraryToManifest(['A'], ' ')).toBeNull(); + }); +}); + +describe('removeLibraryFromManifest', () => { + it('removes by normalized name regardless of version suffix', () => { + expect(removeLibraryFromManifest(['ESP32Servo', 'AsyncTCP'], 'esp32servo@3.2.1')).toEqual([ + 'AsyncTCP', + ]); + }); + + it('returns null when the library is not declared', () => { + expect(removeLibraryFromManifest(['A'], 'B')).toBeNull(); + expect(removeLibraryFromManifest([], 'B')).toBeNull(); + expect(removeLibraryFromManifest(undefined, 'B')).toBeNull(); + }); + + it('can empty the manifest (explicit [] is a valid manifest)', () => { + expect(removeLibraryFromManifest(['OnlyOne'], 'OnlyOne')).toEqual([]); + }); +}); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 759c38cb..34c25866 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -22,6 +22,7 @@ import { reportRunEvent } from '../../services/metricsService'; import { useProjectStore } from '../../store/useProjectStore'; import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal'; +import { mergeSuggestedLibraries } from '../../utils/libraryManifest'; import { parseCompileResult } from '../../utils/compilationLogger'; import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger'; import { exportToWokwiZip } from '../../utils/wokwiZip'; @@ -608,6 +609,18 @@ export const EditorToolbar = ({ if (result.has_wifi !== undefined) { updateBoard(activeBoardId, { hasWifi: result.has_wifi }); } + // P2.4 auto-migration: a green build reports the libraries it + // really used; fold single-candidate ones into this board's + // declared manifest so the NEXT compile runs scoped instead of + // scan-all (the mode where unrelated libraries could leak in). + const mergedLibs = mergeSuggestedLibraries( + activeBoard?.libraries, + result.manifest_suggested_libraries, + ); + if (mergedLibs) { + updateBoard(activeBoardId, { libraries: mergedLibs }); + console.log('[manifest] auto-declared from build:', mergedLibs); + } } setMessage({ type: 'success', text: 'Compiled successfully' }); markCompiled(); diff --git a/frontend/src/components/simulator/InstallLibrariesModal.tsx b/frontend/src/components/simulator/InstallLibrariesModal.tsx index 669aed3f..d8c82070 100644 --- a/frontend/src/components/simulator/InstallLibrariesModal.tsx +++ b/frontend/src/components/simulator/InstallLibrariesModal.tsx @@ -1,6 +1,8 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { installLibrary } from '../../services/libraryService'; +import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { addLibraryToManifest } from '../../utils/libraryManifest'; import './InstallLibrariesModal.css'; interface InstallLibrariesModalProps { @@ -98,6 +100,17 @@ export const InstallLibrariesModal: React.FC = ({ const result = await installLibrary(item.spec); if (result.success) { setItemStatus(item.spec, 'done'); + // Installing must also DECLARE: record the library in the active + // board's manifest so the compile scope knows about it. Without + // this, Wokwi-imported projects compiled via scan-all forever + // (2026-08 library-contamination investigation). + const sim = useSimulatorStore.getState(); + const boardId = sim.activeBoardId; + const board = sim.boards.find((b) => b.id === boardId); + if (boardId && board) { + const next = addLibraryToManifest(board.libraries, item.spec); + if (next) sim.updateBoard(boardId, { libraries: next }); + } } else { setItemStatus(item.spec, 'error', result.error || 'Install failed'); } diff --git a/frontend/src/services/compilation.ts b/frontend/src/services/compilation.ts index 989d8daf..abf3247f 100644 --- a/frontend/src/services/compilation.ts +++ b/frontend/src/services/compilation.ts @@ -39,6 +39,11 @@ export interface CompileResult { stderr: string; error?: string; core_install_log?: string; + /** P2.4 — the manifest is missing libraries the build really used. */ + manifest_incomplete?: boolean; + /** { header: [candidate library display names] } — single-candidate + * entries are safe to auto-declare (see utils/libraryManifest.ts). */ + manifest_suggested_libraries?: Record | null; } interface CompileStartResponse { diff --git a/frontend/src/utils/libraryManifest.ts b/frontend/src/utils/libraryManifest.ts new file mode 100644 index 00000000..97c61bb8 --- /dev/null +++ b/frontend/src/utils/libraryManifest.ts @@ -0,0 +1,82 @@ +/** + * Library-manifest helpers — pure functions, no store imports. + * + * Why this exists (2026-08 library-contamination investigation): 79% of + * projects compile with an EMPTY manifest, which forces the backend into + * scan-all resolution over ~1000 shared libraries — the mode where an + * unrelated library can be dragged into the build. Rather than ever + * breaking those projects, the backend now reports the libraries a + * successful scan-all build actually used (`manifest_suggested_libraries`, + * shape { header: [candidateNames] }), and the frontend folds them into the + * board's declared manifest here. One green build = one migrated project. + */ + +/** Library-name normalization, mirroring the backend's `_norm_lib_name`: + * lowercase, alphanumerics only, so "Adafruit GFX Library", + * "Adafruit_GFX_Library" and "adafruitgfxlibrary" all compare equal. */ +export function normalizeLibName(name: string): string { + return (name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** Strip a trailing "@version" from a spec ("DHT sensor library@1.4.6"). */ +export function bareLibName(spec: string): string { + const at = spec.lastIndexOf('@'); + return at > 0 ? spec.slice(0, at) : spec; +} + +/** + * Merge the backend's suggested libraries into the current manifest. + * + * Conservative by design: + * - only entries with EXACTLY ONE candidate are taken (an ambiguous + * suggestion must never guess its way into the manifest); + * - names already present (normalized comparison) are skipped; + * - returns `null` when there is nothing new, so callers can cheaply + * detect "no change" and avoid a store write / autosave churn. + */ +export function mergeSuggestedLibraries( + current: string[] | undefined, + suggested: Record | null | undefined, +): string[] | null { + if (!suggested) return null; + const have = new Set((current ?? []).map(normalizeLibName)); + const additions: string[] = []; + for (const candidates of Object.values(suggested)) { + if (!Array.isArray(candidates) || candidates.length !== 1) continue; + const name = String(candidates[0] ?? '').trim(); + if (!name) continue; + const norm = normalizeLibName(name); + if (!norm || have.has(norm)) continue; + have.add(norm); + additions.push(name); + } + if (!additions.length) return null; + return [...(current ?? []), ...additions]; +} + +/** + * Add one just-installed library to a manifest (install-declares flow). + * Returns `null` when it is already declared. + */ +export function addLibraryToManifest( + current: string[] | undefined, + spec: string, +): string[] | null { + const name = bareLibName(spec).trim(); + if (!name) return null; + const norm = normalizeLibName(name); + const have = new Set((current ?? []).map(normalizeLibName)); + if (have.has(norm)) return null; + return [...(current ?? []), name]; +} + +/** Remove a library from a manifest (uninstall flow). Null = no change. */ +export function removeLibraryFromManifest( + current: string[] | undefined, + spec: string, +): string[] | null { + if (!current?.length) return null; + const norm = normalizeLibName(bareLibName(spec)); + const next = current.filter((n) => normalizeLibName(n) !== norm); + return next.length === current.length ? null : next; +} diff --git a/frontend/src/utils/vlxFile.ts b/frontend/src/utils/vlxFile.ts index c6cf5f92..5ac6795b 100644 --- a/frontend/src/utils/vlxFile.ts +++ b/frontend/src/utils/vlxFile.ts @@ -53,6 +53,8 @@ export interface VlxPayload { activeFileGroupId: string; languageMode?: string; serialBaudRate?: number; + /** Declared library manifest (compile scope). Absent in old files. */ + libraries?: string[]; }>; fileGroups: Record>; components: Component[]; @@ -70,6 +72,10 @@ function serialisableBoard(b: BoardInstance) { activeFileGroupId: b.activeFileGroupId, languageMode: b.languageMode, serialBaudRate: b.serialBaudRate, + // The declared manifest must survive the .vlx round-trip: dropping it + // silently reverted re-imported projects to scan-all resolution + // (2026-08 library-contamination investigation). + libraries: b.libraries, }; } diff --git a/test/backend/unit/test_espidf_library_ranking.py b/test/backend/unit/test_espidf_library_ranking.py new file mode 100644 index 00000000..d96be989 --- /dev/null +++ b/test/backend/unit/test_espidf_library_ranking.py @@ -0,0 +1,148 @@ +"""Name-affinity ranking + merged-library reporting in the ESP-IDF resolver. + +Two behaviours under test, both from the 2026-08 library-contamination +investigation: + +1. `_find_library_for_header` used to return the FIRST alphabetical + provider. has 6 providers in the live cache, so a random + stray could win over the library actually named after the header. + Now the best-named candidate wins (exact normalized name > esp32-arch + declaration > contains-stem), alphabetical only as tie-break. + +2. `_resolve_library_components` reports which library it merged for each + header (display name from library.properties, version suffix stripped) + via the optional `merged_libs` out-param. That report becomes + `manifest_suggested_libraries` on scan-all compiles, which the frontend + uses to auto-complete the project manifest — the migration path for the + ~79% of projects that still compile with an empty manifest. + +No toolchain required: pure resolution logic. +""" + +import sys +import tempfile +import shutil +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend')) + +from app.services.espidf_compiler import ESPIDFCompiler + + +def _mk(p: Path, content: str = "x") -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + + +class TestNameAffinityRanking(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.libs = self.tmp / "libraries" + self.c = ESPIDFCompiler() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_exact_name_beats_alphabetical(self): + # Sorts FIRST alphabetically but is unrelated to the header. + _mk(self.libs / "aaaa-stray-robot" / "src" / "Servo.h") + _mk(self.libs / "aaaa-stray-robot" / "library.properties", + "name=AAAA Stray Robot\n") + # The library actually named after the header. + _mk(self.libs / "servo@1.2.0-cafebabe" / "src" / "Servo.h") + _mk(self.libs / "servo@1.2.0-cafebabe" / "library.properties", + "name=Servo\narchitectures=*\n") + got = self.c._find_library_for_header("Servo.h", self.libs) + self.assertIsNotNone(got) + self.assertIn("servo@1.2.0", str(got)) + + def test_esp32_arch_beats_wildcard_when_no_name_match(self): + _mk(self.libs / "alpha-generic" / "src" / "Foo.h") + _mk(self.libs / "alpha-generic" / "library.properties", + "name=Alpha Generic\narchitectures=*\n") + _mk(self.libs / "zeta-esp32-port" / "src" / "Foo.h") + _mk(self.libs / "zeta-esp32-port" / "library.properties", + "name=Zeta ESP32 Port\narchitectures=esp32\n") + got = self.c._find_library_for_header("Foo.h", self.libs) + self.assertIn("zeta-esp32-port", str(got)) + + def test_contains_stem_prefers_shorter_name(self): + _mk(self.libs / "simpleservoesp32" / "src" / "Bar.h") + _mk(self.libs / "simpleservoesp32" / "library.properties", + "name=SimpleBarKitDeluxeEdition\n") + _mk(self.libs / "zzz-bar-lib" / "src" / "Bar.h") + _mk(self.libs / "zzz-bar-lib" / "library.properties", "name=BarLib\n") + got = self.c._find_library_for_header("Bar.h", self.libs) + self.assertIn("zzz-bar-lib", str(got)) + + def test_single_provider_still_resolves_with_zero_score(self): + # Legacy behaviour preserved: an unrelated name is still a match + # when it is the only provider. + _mk(self.libs / "whatever" / "Baz.h") + got = self.c._find_library_for_header("Baz.h", self.libs) + self.assertIn("whatever", str(got)) + + def test_no_provider_returns_none(self): + _mk(self.libs / "something" / "Other.h") + self.assertIsNone(self.c._find_library_for_header("Missing.h", self.libs)) + + +class TestMergedLibraryReport(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.libs = self.tmp / "libraries" + self.out = self.tmp / "project" / "user_libs" + self.out.mkdir(parents=True) + self.c = ESPIDFCompiler() + self.c.arduino_path = "" + self.c._core_headers_cache = None + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def _resolve(self, headers, merged): + return self.c._resolve_library_components( + headers, arduino_libs=self.libs, esp32_libs=None, + arduino_comp_name="arduino-esp32", user_libs_dir=self.out, + merged_libs=merged, + ) + + def test_reports_display_name_not_cache_folder(self): + _mk(self.libs / "espasyncwebserver@3.11.2-e82094d0" / "src" / "ESPAsyncWebServer.h") + _mk(self.libs / "espasyncwebserver@3.11.2-e82094d0" / "library.properties", + "name=ESP Async WebServer\narchitectures=*\n") + merged: dict[str, str] = {} + self._resolve(["ESPAsyncWebServer.h"], merged) + self.assertEqual(merged, {"ESPAsyncWebServer.h": "ESP Async WebServer"}) + + def test_falls_back_to_folder_name_without_version_suffix(self): + _mk(self.libs / "esp32servo@3.2.1-577147ab" / "src" / "ESP32Servo.h") + # library.properties present but nameless + _mk(self.libs / "esp32servo@3.2.1-577147ab" / "library.properties", "author=x\n") + merged: dict[str, str] = {} + self._resolve(["ESP32Servo.h"], merged) + self.assertEqual(merged, {"ESP32Servo.h": "esp32servo"}) + + def test_transitive_pulls_are_reported_too(self): + _mk(self.libs / "toplib" / "src" / "Top.h", "#include \n") + _mk(self.libs / "toplib" / "library.properties", "name=TopLib\n") + _mk(self.libs / "deplib" / "src" / "Dep.h") + _mk(self.libs / "deplib" / "library.properties", "name=DepLib\n") + merged: dict[str, str] = {} + self._resolve(["Top.h"], merged) + self.assertEqual(merged.get("Top.h"), "TopLib") + self.assertEqual(merged.get("Dep.h"), "DepLib") + + def test_none_out_param_keeps_legacy_shape(self): + _mk(self.libs / "somelib" / "src" / "A.h") + names, hdr2comp = self.c._resolve_library_components( + ["A.h"], arduino_libs=self.libs, esp32_libs=None, + arduino_comp_name="arduino-esp32", user_libs_dir=self.out, + ) + self.assertEqual(names, ["user_libs_all"]) + self.assertEqual(hdr2comp.get("A.h"), "user_libs_all") + + +if __name__ == "__main__": + unittest.main()