268 lines
9.8 KiB
Python
268 lines
9.8 KiB
Python
"""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())
|
|
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)
|
|
|
|
|
|
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"
|
|
|
|
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()
|