velxio/backend/tests/test_symbol_extract.py

294 lines
11 KiB
Python
Raw Normal View History

"""Unit tests for the intellisense symbol scanner and its library-cache
resolution helpers. Pure-Python — no FastAPI app, no HTTP server, no
toolchain — so they run anywhere pytest does.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.symbol_extract import (
find_cache_entry,
norm_name,
norm_version,
parse_spec,
scan_library,
)
# ── fixture library ───────────────────────────────────────────────────────
_HEADER = """\
#pragma once
#include <stdint.h>
#define FAKE_MAX_PIXELS 64
#define FAKE_NAME "fake" // string-valued define still counts
#define FAKE_FUNC(x) ((x) * 2)
#define lowercase_define 1
#define FAKE_GUARD_H
enum FakeMode { FAKE_MODE_A, FAKE_MODE_B = 2 };
enum class FakeColor : uint8_t { Red, Green };
/* block comment with a fake decl: void notReal(); */
class FakeLib {
public:
FakeLib(uint8_t pin);
~FakeLib();
bool begin(uint8_t pin, int count = 8);
void show();
private:
void internalUpdate();
uint8_t pin_;
};
"""
@pytest.fixture
def fake_lib(tmp_path: Path) -> Path:
lib = tmp_path / "fakelib@1.2.3-0123456789ab"
src = lib / "src"
src.mkdir(parents=True)
(lib / "library.properties").write_text(
"name=Fake Lib\nversion=1.2.3\n", encoding="utf-8"
)
(src / "FakeLib.h").write_text(_HEADER, encoding="utf-8")
examples = lib / "examples"
examples.mkdir()
(examples / "Noise.h").write_text(
"class NotMe { public: void nope(); };\n", encoding="utf-8"
)
return lib
def _by_kind(result: dict, kind: str) -> dict[str, dict]:
return {s["name"]: s for s in result["symbols"] if s["kind"] == kind}
# ── scan_library ──────────────────────────────────────────────────────────
def test_scan_library_id_and_triggers(fake_lib: Path) -> None:
result = scan_library(fake_lib)
assert result["id"] == "Fake Lib"
assert "FakeLib.h" in result["triggers"]
# examples/ headers are neither triggers nor scanned for symbols
assert "Noise.h" not in result["triggers"]
assert all(s["name"] != "NotMe" for s in result["symbols"])
def test_scan_library_class_and_public_methods(fake_lib: Path) -> None:
result = scan_library(fake_lib)
classes = _by_kind(result, "class")
methods = _by_kind(result, "method")
assert "FakeLib" in classes
# constructor is folded into the class symbol, not a separate method
assert classes["FakeLib"]["insertText"] == "FakeLib(${1:pin})$0"
assert classes["FakeLib"]["params"] == ["uint8_t pin"]
assert set(methods) == {"begin", "show"}
for m in methods.values():
assert m["owner"] == "FakeLib"
assert m["detail"] == "Fake Lib"
assert methods["begin"]["signature"] == "bool begin(uint8_t pin, int count = 8)"
# only the required (non-defaulted) arg becomes a snippet placeholder
assert methods["begin"]["insertText"] == "begin(${1:pin})$0"
assert methods["begin"]["params"] == ["uint8_t pin", "int count = 8"]
assert methods["show"]["insertText"] == "show()"
# private method and destructor never surface
assert "internalUpdate" not in methods
assert all("~" not in s["name"] for s in result["symbols"])
def test_scan_library_constants_and_enums(fake_lib: Path) -> None:
result = scan_library(fake_lib)
constants = _by_kind(result, "constant")
assert "FAKE_MAX_PIXELS" in constants
assert "FAKE_NAME" in constants
# function-like macro, lowercase define, and value-less include guard: out
assert "FAKE_FUNC" not in constants
assert "lowercase_define" not in constants
assert "FAKE_GUARD_H" not in constants
# plain enum values: no owner; enum class values: owner set
assert "FAKE_MODE_A" in constants and "owner" not in constants["FAKE_MODE_A"]
assert "FAKE_MODE_B" in constants
assert constants["Red"]["owner"] == "FakeColor"
assert constants["Green"]["owner"] == "FakeColor"
# ── spec parsing / normalization (used by the route) ──────────────────────
def test_access_label_survives_a_nested_enum(tmp_path: Path) -> None:
"""`public:` followed by a nested typedef enum used to take the enum branch
of the walker without ever applying the access label, so the class stayed
private and every method after the enum vanished. AccelStepper shipped 4
of its ~50 public methods that way; DallasTemperature shipped 0."""
lib = tmp_path / "steplib@1.0.0-abcdefabcdef"
src = lib / "src"
src.mkdir(parents=True)
(lib / "library.properties").write_text("name=Step Lib\nversion=1.0.0\n", encoding="utf-8")
(src / "Step.h").write_text(
"""\
class Stepper {
public:
typedef enum { FUNCTION = 0, DRIVER = 1 } MotorInterfaceType;
Stepper(uint8_t iface = DRIVER);
void moveTo(long absolute);
bool run();
protected:
void step(long n);
private:
int _pin;
public:
void setMaxSpeed(float speed);
};
""",
encoding="utf-8",
)
result = scan_library(lib)
methods = _by_kind(result, "method")
assert set(methods) == {"moveTo", "run", "setMaxSpeed"}
assert all(m["owner"] == "Stepper" for m in methods.values())
feat(intellisense): /type/ follows inheritance; extractor records bases and splits comma declarators Measured against the user corpus, the by-resolved-type long tail was losing what the old catch-all happened to include: for `Adafruit_SH1106G display(...); display.setTextSize(` the class itself declares one method (begin) and everything people call lives four levels up (Adafruit_SH110X -> Adafruit_GrayOLED -> Adafruit_GFX -> Print). Same story for MCUFRIEND_kbv, Adafruit_NeoMatrix and every GFX-derived display driver. Three changes: - The scanner records `bases` on class symbols (access/virtual keywords dropped, `ns::Base` -> Base, template args cut). 2,529 classes across the production cache now carry their bases. - /type/{name} walks the chain breadth-first through the type index (bases routinely live in a different library), re-owns inherited members to the requested type so the editor sees one flat class, tags them with `inheritedFrom`, and lists the chain in `bases` -- including bases NOT in the cache (Print, Stream, or an embedded one like Adafruit_GFX) so the host can supply those itself. Depth capped at 8. Adafruit_SH1106G goes from 1 member to 64. - `void a(int), b(), c(char);` declares three methods; the extractor took only the first. TFT_eSPI declares setTextColor/setTextSize and setCursor/getCursorX/... this way. Split on top-level commas after a complete `name(args)` group. TFT_eSPI 118 -> 173 members. Rescan of the 1,125-entry production cache: +274 member symbols, 15 libraries gain, none lose.
2026-08-18 00:08:57 +07:00
assert _by_kind(result, "class")["Stepper"].get("bases") is None
assert "step" not in methods and "_pin" not in methods
assert {"FUNCTION", "DRIVER"} <= set(_by_kind(result, "constant"))
def test_parse_spec() -> None:
assert parse_spec("Adafruit GFX Library@1.12.6") == ("Adafruit GFX Library", "1.12.6")
assert parse_spec("FastLED") == ("FastLED", None)
assert parse_spec("") == ("", None)
def test_norm_rules_mirror_library_cache() -> None:
assert norm_name("Adafruit GFX Library") == "adafruitgfxlibrary"
assert norm_name("DHT sensor library") == "dhtsensorlibrary"
assert norm_name("") == ""
assert norm_version("1.12.6") == "1.12.6"
assert norm_version(" 1.2.3 ") == "1.2.3"
# unsafe chars become '-', leading dots are stripped (no '.'/'..' tokens)
assert norm_version("../evil/1") == "-evil-1"
assert norm_version("") == "0"
def test_find_cache_entry(tmp_path: Path) -> None:
for entry in (
"fastled@3.6.0-aaaaaaaaaaaa",
"fastled@3.10.1-bbbbbbbbbbbb",
"other@1.0.0-cccccccccccc",
".symbols",
):
(tmp_path / entry).mkdir()
exact = find_cache_entry(tmp_path, "FastLED", "3.6.0")
assert exact is not None and exact.name == "fastled@3.6.0-aaaaaaaaaaaa"
# bare name -> NEWEST version, compared numerically (3.10.1 > 3.6.0 even
# though it sorts first lexicographically) — mirrors lookup_by_name
bare = find_cache_entry(tmp_path, "FastLED")
assert bare is not None and bare.name == "fastled@3.10.1-bbbbbbbbbbbb"
assert find_cache_entry(tmp_path, "FastLED", "9.9.9") is None
assert find_cache_entry(tmp_path, "Nope") is None
assert find_cache_entry(tmp_path / "missing", "FastLED") is None
assert find_cache_entry(tmp_path, "!!!") is None # normalizes to empty
# ── route wiring (skipped where fastapi is not installed) ─────────────────
def test_route_registers_symbols_path() -> None:
pytest.importorskip("fastapi")
from app.api.routes import intellisense
assert any(r.path == "/symbols/{spec}" for r in intellisense.router.routes)
feat(intellisense): GET /api/intellisense/type/{name} — members of one class The completion-safe long-tail path. Feeding whole library symbol sets into the editor's candidate pool was measured to make ranking worse (U8g2 alone injects ~250 module-level class names; ArduinoJson dozens of ARDUINOJSON_* macros). What does help is answering `client.` once tree-sitter has resolved `client` to a declared type the embedded catalog does not know -- and for that the engine needs "the members of type T", not "the symbols of library L". This endpoint returns exactly that: symbols whose owner is the requested class, from whichever cached library declares it, in the same payload shape as /symbols/ so the engine's RemoteSymbolCache stores it unchanged. Resolution: a class-name -> cache-entry index built once over the library cache (each per-entry scan is already disk-cached), persisted under .symbols/_types.json (3,818 classes / 199 KB over the 1,125-entry production cache; ~1 s to rebuild from cached scans, ~60 s cold), refreshed after a few hours or when the cache grows. Many classes are declared by more than one library -- the real one plus forks and bundles vendoring a copy (PubSubClient inside "ESP8266 Microgear", NTPClient inside "ESP8266 Weather Station", AccelStepper inside "Arduino Learning Board") -- so the pick is: the library named after the class, else the one declaring the most members of it (vendored copies are trimmed), else alphabetical. Generic, not pro-specific: any host with a library cache can use it.
2026-08-17 23:51:08 +07:00
def test_type_endpoint_returns_only_the_declaring_class_members(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""/type/{name} is the completion-safe long-tail path: only the members
of that class, never a library's module-level constants. And when two
libraries declare the same class, the one NAMED after it wins over a
fork/bundle that vendors a trimmed copy."""
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from app.api.routes import intellisense
root = tmp_path / "libcache"
def lib(dirname: str, display: str, header: str) -> None:
d = root / dirname / "src"
d.mkdir(parents=True)
(root / dirname / "library.properties").write_text(
f"name={display}\nversion=1.0.0\n", encoding="utf-8"
)
(d / "Lib.h").write_text(header, encoding="utf-8")
# The canonical library: named after the class, full API + a macro.
lib(
"pubsubclient@2.8-aaaaaaaaaaaa",
"PubSubClient",
"#define MQTT_MAX_PACKET_SIZE 256\n"
"class PubSubClient {\npublic:\n void setServer(const char*, int);\n"
" bool publish(const char*, const char*);\n bool connected();\n};\n",
)
# A bundle that vendors a trimmed copy under another name.
lib(
"espbundle@1.0.0-bbbbbbbbbbbb",
"ESP Bundle",
"class PubSubClient {\npublic:\n bool publish(const char*, const char*);\n};\n"
"class BundleThing {\npublic:\n void go();\n};\n",
)
monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(root))
app = fastapi.FastAPI()
app.include_router(intellisense.router, prefix="/api/intellisense")
client = TestClient(app)
r = client.get("/api/intellisense/type/PubSubClient")
assert r.status_code == 200
body = r.json()
assert body["id"] == "PubSubClient" # the canonical one, not the bundle
names = {s["name"] for s in body["symbols"]}
assert names == {"setServer", "publish", "connected"}
assert all(s["owner"] == "PubSubClient" for s in body["symbols"])
assert "MQTT_MAX_PACKET_SIZE" not in names # module-level never leaks
r = client.get("/api/intellisense/type/BundleThing")
assert r.status_code == 200 and r.json()["id"] == "ESP Bundle"
feat(intellisense): /type/ follows inheritance; extractor records bases and splits comma declarators Measured against the user corpus, the by-resolved-type long tail was losing what the old catch-all happened to include: for `Adafruit_SH1106G display(...); display.setTextSize(` the class itself declares one method (begin) and everything people call lives four levels up (Adafruit_SH110X -> Adafruit_GrayOLED -> Adafruit_GFX -> Print). Same story for MCUFRIEND_kbv, Adafruit_NeoMatrix and every GFX-derived display driver. Three changes: - The scanner records `bases` on class symbols (access/virtual keywords dropped, `ns::Base` -> Base, template args cut). 2,529 classes across the production cache now carry their bases. - /type/{name} walks the chain breadth-first through the type index (bases routinely live in a different library), re-owns inherited members to the requested type so the editor sees one flat class, tags them with `inheritedFrom`, and lists the chain in `bases` -- including bases NOT in the cache (Print, Stream, or an embedded one like Adafruit_GFX) so the host can supply those itself. Depth capped at 8. Adafruit_SH1106G goes from 1 member to 64. - `void a(int), b(), c(char);` declares three methods; the extractor took only the first. TFT_eSPI declares setTextColor/setTextSize and setCursor/getCursorX/... this way. Split on top-level commas after a complete `name(args)` group. TFT_eSPI 118 -> 173 members. Rescan of the 1,125-entry production cache: +274 member symbols, 15 libraries gain, none lose.
2026-08-18 00:08:57 +07:00
# Inheritance is followed through the index, members re-owned to the
# requested type and tagged with where they came from; bases outside the
# cache are still listed so the host can supply them.
lib(
"gfxlike@1.0.0-cccccccccccc",
"GfxLike",
"class GfxBase : public Print {\npublic:\n void drawPixel(int x, int y);\n"
" void setTextSize(uint8_t s), setTextColor(uint16_t c);\n};\n",
)
lib(
"oledlike@1.0.0-dddddddddddd",
"OledLike",
"class Oled : public GfxBase {\npublic:\n bool begin();\n};\n",
)
# cache grew -> index rebuilds
r = client.get("/api/intellisense/type/Oled")
assert r.status_code == 200
body = r.json()
assert body["bases"] == ["GfxBase", "Print"]
by_name = {s["name"]: s for s in body["symbols"]}
assert set(by_name) == {"begin", "drawPixel", "setTextSize", "setTextColor"}
assert "inheritedFrom" not in by_name["begin"]
assert by_name["setTextSize"]["inheritedFrom"] == "GfxBase"
assert all(s["owner"] == "Oled" for s in body["symbols"])
feat(intellisense): GET /api/intellisense/type/{name} — members of one class The completion-safe long-tail path. Feeding whole library symbol sets into the editor's candidate pool was measured to make ranking worse (U8g2 alone injects ~250 module-level class names; ArduinoJson dozens of ARDUINOJSON_* macros). What does help is answering `client.` once tree-sitter has resolved `client` to a declared type the embedded catalog does not know -- and for that the engine needs "the members of type T", not "the symbols of library L". This endpoint returns exactly that: symbols whose owner is the requested class, from whichever cached library declares it, in the same payload shape as /symbols/ so the engine's RemoteSymbolCache stores it unchanged. Resolution: a class-name -> cache-entry index built once over the library cache (each per-entry scan is already disk-cached), persisted under .symbols/_types.json (3,818 classes / 199 KB over the 1,125-entry production cache; ~1 s to rebuild from cached scans, ~60 s cold), refreshed after a few hours or when the cache grows. Many classes are declared by more than one library -- the real one plus forks and bundles vendoring a copy (PubSubClient inside "ESP8266 Microgear", NTPClient inside "ESP8266 Weather Station", AccelStepper inside "Arduino Learning Board") -- so the pick is: the library named after the class, else the one declaring the most members of it (vendored copies are trimmed), else alphabetical. Generic, not pro-specific: any host with a library cache can use it.
2026-08-17 23:51:08 +07:00
assert client.get("/api/intellisense/type/Nope").status_code == 404
assert client.get("/api/intellisense/type/not-a-type!").status_code == 400
# the index is persisted and reused
assert (root / ".symbols" / "_types.json").is_file()