velxio/test/backend/unit/test_espidf_library_ranking.py

216 lines
9.3 KiB
Python

"""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. <Servo.h> 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 <Dep.h>\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")
class TestProjectSuppliedHeaders(unittest.TestCase):
"""A config header the PROJECT owns must never drag in a library.
The live failure (2026-08-28, an LVGL clock on a XIAO ESP32-S3): the
sketch included only <lvgl.h>, but LVGL's sources carry
`#include "lv_conf.h"` behind `#if !LV_CONF_SKIP`. The include scan is
textual, so lv_conf.h got queued even though the build defined
LV_CONF_SKIP=1, resolved to Adafruit LvGL Glue (which vendors its own
copy for LVGL v7), and merged that whole library. Its sources then failed
against LVGL v8 and the build died naming a library the user never asked
for.
"""
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 _make_lvgl_world(self) -> None:
# LVGL itself: lvgl.h pulls lv_conf.h the way the real sources do.
_mk(self.libs / "lvgl@8.3.11-0d103ab1" / "src" / "lvgl.h",
'#include "lv_conf.h"\n')
_mk(self.libs / "lvgl@8.3.11-0d103ab1" / "library.properties",
"name=lvgl\narchitectures=*\n")
# The unrelated library that happens to vendor an lv_conf.h.
_mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "src" / "lv_conf.h")
_mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "src"
/ "Adafruit_LvGL_Glue.h")
_mk(self.libs / "adafruitlittlevglgluelibrary@2.1.7-896f753c" / "library.properties",
"name=Adafruit LvGL Glue Library\narchitectures=*\n")
def test_lv_conf_does_not_drag_in_the_glue_library(self):
self._make_lvgl_world()
merged: dict[str, str] = {}
self._resolve(["lvgl.h"], merged)
self.assertEqual(merged.get("lvgl.h"), "lvgl")
self.assertNotIn("lv_conf.h", merged)
self.assertNotIn(
"Adafruit LvGL Glue Library", merged.values(),
"a sketch that only includes <lvgl.h> must not merge the glue library",
)
def test_a_library_that_owns_lv_conf_is_still_reachable_by_its_own_header(self):
# The guard is scoped to the config header, not to the library: a
# project that really wants the glue can still include its header.
self._make_lvgl_world()
merged: dict[str, str] = {}
self._resolve(["Adafruit_LvGL_Glue.h"], merged)
self.assertEqual(
merged.get("Adafruit_LvGL_Glue.h"), "Adafruit LvGL Glue Library"
)
if __name__ == "__main__":
unittest.main()