feat(libraries): manifest auto-migration + name-affinity ranking + install-declares
Three-part fix for the library-contamination class of ESP32 compile failures (a user's project kept pulling unrelated libraries because 79% of projects compile with an empty manifest, forcing scan-all resolution over ~1000 shared libraries): 1. Name-affinity ranking in _find_library_for_header: the provider whose name matches the header stem wins (exact normalized match > esp32 arch declaration > contains-stem, alphabetical only as tie-break). Previously first-alphabetical won, so any stray library shipping a generic header (Servo.h has 6 providers) could hijack the build. 2. Manifest auto-migration: scan-all compiles now report the libraries the build really used (manifest_incomplete + manifest_suggested_libraries on the compile result); the editor folds single-candidate suggestions into the board's declared manifest after a green build, so the NEXT compile runs scoped. One green build = one migrated project; empty-manifest scan-all behaviour is untouched. 3. Install-declares: the Wokwi import library modal records successful installs in the active board's manifest, and .vlx export/import now round-trips board.libraries. Tests: test_espidf_library_ranking.py (9) + library-manifest.test.ts (19).
This commit is contained in:
parent
0c65de16ad
commit
cd67362312
|
|
@ -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 <Servo.h> (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-<hash>"
|
||||
# — 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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<InstallLibrariesModalProps> = ({
|
|||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> | null;
|
||||
}
|
||||
|
||||
interface CompileStartResponse {
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> | 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;
|
||||
}
|
||||
|
|
@ -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<string, Array<{ name: string; content: string }>>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. <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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue